From 03d519e57affc40a97f4fc447726eb46af298eac Mon Sep 17 00:00:00 2001 From: rory Date: Mon, 11 May 2026 12:42:23 -0700 Subject: [PATCH 01/11] Make deploy workflow manually dispatchable Add a workflow_dispatch trigger so deployers can manually re-trigger a staging or production deploy without needing a new push to the branch. When manually triggered, the workflow checks out the branch corresponding to the ENVIRONMENT input and derives all environment-specific behavior from a DEPLOY_ENV env var rather than github.ref. Also guard the isCherryPick script against the missing commits payload on non-push triggers, and add a DEPLOY_SHA output so all downstream jobs check out the correct commit. Update cherry-pick conflict PR descriptions to note that the PR should ideally be merged by a deployer; if not, the deploy will fail and must be manually re-triggered (not retried). Fixes https://github.com/Expensify/App/issues/89934 Co-authored-by: Cursor --- .github/workflows/cherryPick.yml | 2 + .github/workflows/deploy.yml | 85 ++++++++++++++++++++------------ 2 files changed, 56 insertions(+), 31 deletions(-) diff --git a/.github/workflows/cherryPick.yml b/.github/workflows/cherryPick.yml index 44d8a34ee73a..82a46c27a2b4 100644 --- a/.github/workflows/cherryPick.yml +++ b/.github/workflows/cherryPick.yml @@ -356,6 +356,8 @@ jobs: Note that you **must** test this PR, and both the author and reviewer checklist should be completed, just as if you were merging the PR to main. + **Important:** This PR should ideally be merged by a member of the [mobile-deployers](https://github.com/orgs/Expensify/teams/mobile-deployers) team. If it is merged by someone who is not a deployer, the staging deploy triggered by the merge will fail and will need to be manually re-triggered (not just retried) via the [deploy workflow](https://github.com/${{ github.repository }}/actions/workflows/deploy.yml). + _Pro-tip:_ If this PR appears to have conflicts against the _${{ inputs.TARGET }}_ base, it means that the version on ${{ inputs.TARGET }} has been updated. The easiest thing to do if you see this is to close the PR and re-run the CP. $AUTHOR_CHECKLIST diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 56b0de5fd104..8bbb03953fee 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -3,12 +3,22 @@ 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 }}-${{ github.ref }} + group: ${{ github.workflow }}-${{ inputs.ENVIRONMENT || github.ref }} cancel-in-progress: true jobs: @@ -17,10 +27,11 @@ jobs: outputs: APP_VERSION: ${{ steps.getAppVersion.outputs.VERSION }} TAG: ${{ steps.getTagName.outputs.TAG }} + DEPLOY_SHA: ${{ steps.getDeploySHA.outputs.SHA }} # 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) }} + 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: @@ -28,9 +39,14 @@ jobs: # v6 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: + ref: ${{ inputs.ENVIRONMENT || github.ref }} token: ${{ secrets.OS_BOTIFY_TOKEN }} submodules: true + - name: Get deploy SHA + id: getDeploySHA + run: echo "SHA=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + - name: Validate actor id: validateActor uses: ./.github/actions/composite/validateActor @@ -52,7 +68,7 @@ jobs: - 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" + 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: | @@ -69,7 +85,7 @@ jobs: uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 with: script: | - const commitMessages = context.payload.commits.map((commit) => commit.message); + 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( @@ -89,7 +105,7 @@ jobs: deployChecklist: name: Create or update deploy checklist uses: ./.github/workflows/createDeployChecklist.yml - if: ${{ github.ref == 'refs/heads/staging' }} + if: ${{ env.DEPLOY_ENV == 'staging' }} needs: prep secrets: inherit @@ -99,7 +115,7 @@ jobs: if: ${{ fromJSON(needs.prep.outputs.SHOULD_BUILD_NATIVE) }} uses: ./.github/workflows/buildAndroid.yml with: - ref: ${{ github.sha }} + ref: ${{ needs.prep.outputs.DEPLOY_SHA }} variant: Release secrets: inherit @@ -112,6 +128,8 @@ jobs: - name: Checkout # v6 uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98 + with: + ref: ${{ needs.prep.outputs.DEPLOY_SHA }} - name: Download Android build artifact # v7 @@ -160,11 +178,13 @@ jobs: 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' }} + if: ${{ always() && !cancelled() && env.DEPLOY_ENV == 'production' && needs.androidBuild.result != 'failure' && needs.androidUploadGooglePlay.result != 'failure' }} steps: - name: Checkout # v6 uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98 + with: + ref: ${{ needs.prep.outputs.DEPLOY_SHA }} - name: Setup Ruby # v1.229.0 @@ -258,7 +278,7 @@ jobs: 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) }} + if: ${{ github.repository == 'Expensify/App' && env.DEPLOY_ENV == 'staging' && !fromJSON(needs.prep.outputs.IS_CHERRY_PICK) }} continue-on-error: true steps: - name: Download Android APK artifact @@ -308,7 +328,7 @@ jobs: if: ${{ fromJSON(needs.prep.outputs.SHOULD_BUILD_NATIVE) }} uses: ./.github/workflows/buildIOS.yml with: - ref: ${{ github.sha }} + ref: ${{ needs.prep.outputs.DEPLOY_SHA }} variant: Release secrets: inherit @@ -324,6 +344,7 @@ jobs: # v6 uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98 with: + ref: ${{ needs.prep.outputs.DEPLOY_SHA }} token: ${{ secrets.OS_BOTIFY_TOKEN }} submodules: true @@ -396,13 +417,15 @@ jobs: name: Submit iOS for production rollout needs: [prep, iosBuild, iosUploadTestflight] runs-on: blacksmith-12vcpu-macos-latest - if: ${{ always() && !cancelled() && github.ref == 'refs/heads/production' && needs.iosBuild.result != 'failure' && needs.iosUploadTestflight.result != 'failure' }} + if: ${{ always() && !cancelled() && env.DEPLOY_ENV == '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 + with: + ref: ${{ needs.prep.outputs.DEPLOY_SHA }} - name: Setup Ruby # v1.229.0 @@ -488,7 +511,7 @@ jobs: 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) }} + if: ${{ github.repository == 'Expensify/App' && env.DEPLOY_ENV == 'staging' && !fromJSON(needs.prep.outputs.IS_CHERRY_PICK) }} continue-on-error: true steps: - name: Download iOS build artifact @@ -537,8 +560,8 @@ jobs: needs: [prep] uses: ./.github/workflows/buildWeb.yml with: - ref: ${{ github.sha }} - environment: ${{ github.ref == 'refs/heads/production' && 'production' || 'staging' }} + ref: ${{ needs.prep.outputs.DEPLOY_SHA }} + environment: ${{ env.DEPLOY_ENV }} secrets: inherit webDeploy: @@ -551,7 +574,7 @@ jobs: # v6 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: - ref: ${{ github.sha }} + ref: ${{ needs.prep.outputs.DEPLOY_SHA }} - name: Download web build artifact # v7 @@ -588,21 +611,21 @@ jobs: 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 }} + 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: ${{ github.ref == 'refs/heads/production' && vars.WEB_PRODUCTION_HOST || vars.WEB_STAGING_HOST }} + 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: ${{ github.ref == 'refs/heads/production' && vars.WEB_PRODUCTION_HOST || vars.WEB_STAGING_HOST }} + HOST: ${{ env.DEPLOY_ENV == 'production' && vars.WEB_PRODUCTION_HOST || vars.WEB_STAGING_HOST }} buildStorybook: name: Build storybook docs @@ -614,14 +637,14 @@ jobs: # v6 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: - ref: ${{ github.sha }} + ref: ${{ needs.prep.outputs.DEPLOY_SHA }} - name: Setup Node uses: ./.github/actions/composite/setupNode - name: Build storybook docs run: | - if [ "${{ github.ref }}" == "refs/heads/production" ]; then + if [ "${{ env.DEPLOY_ENV }}" == "production" ]; then npm run storybook-build else npm run storybook-build-staging @@ -679,7 +702,7 @@ jobs: channel: '#deployer', attachments: [{ color: 'warning', - text: `⚠️ ${{ github.ref == 'refs/heads/production' && 'Production' || 'Staging' }} <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|deploy> cancelled manually` + text: `⚠️ ${{ env.DEPLOY_ENV == 'production' && 'Production' || 'Staging' }} <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|deploy> cancelled manually` }] } env: @@ -702,7 +725,7 @@ jobs: 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 [ "${{ env.DEPLOY_ENV }}" == "production" ]; then if [ "${{ needs.androidBuild.result }}" == "failure" ] || [ "${{ needs.androidUploadGooglePlay.result }}" == "failure" ]; then echo "ANDROID_RESULT=failure" >> "$GITHUB_OUTPUT" else @@ -716,7 +739,7 @@ jobs: # 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 [ "${{ env.DEPLOY_ENV }}" == "production" ]; then if [ "${{ needs.iosBuild.result }}" == "failure" ] || [ "${{ needs.iosUploadTestflight.result }}" == "failure" ]; then echo "IOS_RESULT=failure" >> "$GITHUB_OUTPUT" else @@ -786,13 +809,13 @@ jobs: 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' || '' }} \ + if gh release create "${{ needs.prep.outputs.TAG }}" ${{ env.DEPLOY_ENV == '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) || '' }} \ + ${{ env.DEPLOY_ENV == 'production' && format('--notes-start-tag {0}', steps.get_last_prod_version.outputs.LAST_PROD_VERSION) || '' }} \ --generate-notes \ --verify-tag \ - --target "${{ github.ref }}"; then + --target "${{ env.DEPLOY_ENV }}"; then break fi if [[ $i -lt $CREATE_MAX_RETRIES ]]; then @@ -865,7 +888,7 @@ jobs: attachments: [{ color: "#DB4545", pretext: ``, - text: `💥 NewDot ${{ github.ref == 'refs/heads/staging' && 'staging' || 'production' }} deploy failed. 💥`, + text: `💥 NewDot ${{ env.DEPLOY_ENV }} deploy failed. 💥`, }] } env: @@ -882,7 +905,7 @@ jobs: # 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) }} + if: ${{ always() && fromJSON(needs.checkDeploymentSuccess.outputs.IS_AT_LEAST_ONE_PLATFORM_DEPLOYED) && env.DEPLOY_ENV == 'production' && fromJSON(needs.prep.outputs.IS_CHERRY_PICK) }} uses: ./.github/workflows/cherryPick.yml secrets: inherit with: @@ -905,7 +928,7 @@ jobs: channel: '#announce', attachments: [{ color: 'good', - text: `🎉️ Successfully deployed ${process.env.AS_REPO} to ${{ github.ref == 'refs/heads/production' && 'production' || 'staging' }} 🎉️`, + text: `🎉️ Successfully deployed ${process.env.AS_REPO} to ${{ env.DEPLOY_ENV }} 🎉️`, }] } env: @@ -922,7 +945,7 @@ jobs: channel: '#deployer', attachments: [{ color: 'good', - text: `🎉️ Successfully deployed ${process.env.AS_REPO} to ${{ github.ref == 'refs/heads/production' && 'production' || 'staging' }} 🎉️`, + text: `🎉️ Successfully deployed ${process.env.AS_REPO} to ${{ env.DEPLOY_ENV }} 🎉️`, }] } env: @@ -932,7 +955,7 @@ jobs: - name: 'Announces a production deploy in the #expensify-open-source Slack room' # v3 uses: 8398a7/action-slack@1750b5085f3ec60384090fb7c52965ef822e869e - if: ${{ github.ref == 'refs/heads/production' }} + if: ${{ env.DEPLOY_ENV == 'production' }} with: status: custom custom_payload: | @@ -954,7 +977,7 @@ jobs: secrets: inherit with: version: ${{ needs.prep.outputs.APP_VERSION }} - env: ${{ github.ref == 'refs/heads/production' && 'production' || 'staging' }} + env: ${{ env.DEPLOY_ENV }} android: ${{ needs.checkDeploymentSuccess.outputs.ANDROID_RESULT }} ios: ${{ needs.checkDeploymentSuccess.outputs.IOS_RESULT }} web: ${{ needs.checkDeploymentSuccess.outputs.WEB_RESULT }} From f11ed25110069ace3e394f3b14901926dd3e6cda Mon Sep 17 00:00:00 2001 From: rory Date: Mon, 11 May 2026 13:01:14 -0700 Subject: [PATCH 02/11] Fix actionlint: expose DEPLOY_ENV as prep job output The env context is not available in job-level if conditions or reusable workflow with inputs. Expose DEPLOY_ENV as a prep job output and use needs.prep.outputs.DEPLOY_ENV in those locations instead. Co-authored-by: Cursor --- .github/workflows/deploy.yml | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8bbb03953fee..d97b33a082b4 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -28,6 +28,7 @@ jobs: 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) @@ -43,9 +44,11 @@ jobs: token: ${{ secrets.OS_BOTIFY_TOKEN }} submodules: true - - name: Get deploy SHA + - name: Get deploy SHA and environment id: getDeploySHA - run: echo "SHA=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + run: | + echo "SHA=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + echo "DEPLOY_ENV=${{ env.DEPLOY_ENV }}" >> "$GITHUB_OUTPUT" - name: Validate actor id: validateActor @@ -105,7 +108,7 @@ jobs: deployChecklist: name: Create or update deploy checklist uses: ./.github/workflows/createDeployChecklist.yml - if: ${{ env.DEPLOY_ENV == 'staging' }} + if: ${{ needs.prep.outputs.DEPLOY_ENV == 'staging' }} needs: prep secrets: inherit @@ -178,7 +181,7 @@ jobs: name: Submit Android for production rollout needs: [prep, androidBuild, androidUploadGooglePlay] runs-on: blacksmith-2vcpu-ubuntu-2404 - if: ${{ always() && !cancelled() && env.DEPLOY_ENV == 'production' && needs.androidBuild.result != 'failure' && needs.androidUploadGooglePlay.result != 'failure' }} + if: ${{ always() && !cancelled() && needs.prep.outputs.DEPLOY_ENV == 'production' && needs.androidBuild.result != 'failure' && needs.androidUploadGooglePlay.result != 'failure' }} steps: - name: Checkout # v6 @@ -278,7 +281,7 @@ jobs: name: Upload Android to Applause needs: [prep, androidBuild] runs-on: blacksmith-2vcpu-ubuntu-2404 - if: ${{ github.repository == 'Expensify/App' && env.DEPLOY_ENV == 'staging' && !fromJSON(needs.prep.outputs.IS_CHERRY_PICK) }} + 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 @@ -417,7 +420,7 @@ jobs: name: Submit iOS for production rollout needs: [prep, iosBuild, iosUploadTestflight] runs-on: blacksmith-12vcpu-macos-latest - if: ${{ always() && !cancelled() && env.DEPLOY_ENV == 'production' && needs.iosBuild.result != 'failure' && needs.iosUploadTestflight.result != 'failure' }} + 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: @@ -511,7 +514,7 @@ jobs: name: Upload iOS to Applause needs: [prep, iosBuild] runs-on: blacksmith-2vcpu-ubuntu-2404 - if: ${{ github.repository == 'Expensify/App' && env.DEPLOY_ENV == 'staging' && !fromJSON(needs.prep.outputs.IS_CHERRY_PICK) }} + 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 @@ -561,7 +564,7 @@ jobs: uses: ./.github/workflows/buildWeb.yml with: ref: ${{ needs.prep.outputs.DEPLOY_SHA }} - environment: ${{ env.DEPLOY_ENV }} + environment: ${{ needs.prep.outputs.DEPLOY_ENV }} secrets: inherit webDeploy: @@ -905,7 +908,7 @@ jobs: # 2. CP that version bump to staging cherryPickExtraVersionBump: needs: [prep, checkDeploymentSuccess] - if: ${{ always() && fromJSON(needs.checkDeploymentSuccess.outputs.IS_AT_LEAST_ONE_PLATFORM_DEPLOYED) && env.DEPLOY_ENV == 'production' && fromJSON(needs.prep.outputs.IS_CHERRY_PICK) }} + if: ${{ always() && fromJSON(needs.checkDeploymentSuccess.outputs.IS_AT_LEAST_ONE_PLATFORM_DEPLOYED) && needs.prep.outputs.DEPLOY_ENV == 'production' && fromJSON(needs.prep.outputs.IS_CHERRY_PICK) }} uses: ./.github/workflows/cherryPick.yml secrets: inherit with: @@ -977,7 +980,7 @@ jobs: secrets: inherit with: version: ${{ needs.prep.outputs.APP_VERSION }} - env: ${{ env.DEPLOY_ENV }} + env: ${{ needs.prep.outputs.DEPLOY_ENV }} android: ${{ needs.checkDeploymentSuccess.outputs.ANDROID_RESULT }} ios: ${{ needs.checkDeploymentSuccess.outputs.IOS_RESULT }} web: ${{ needs.checkDeploymentSuccess.outputs.WEB_RESULT }} From b144a8afca2da7df03b865798b0c79663c3cd5f0 Mon Sep 17 00:00:00 2001 From: rory Date: Mon, 11 May 2026 13:36:15 -0700 Subject: [PATCH 03/11] Fix isCherryPick detection for workflow_dispatch trigger workflow_dispatch payloads contain no commits array, so the previous fallback to [] always produced IS_CHERRY_PICK=false. For a manual redeploy of a cherry-pick to production this caused SHOULD_BUILD_NATIVE and cherryPickExtraVersionBump to be skipped incorrectly. When commits are absent, fetch the HEAD commit message via the GitHub API using the DEPLOY_SHA captured earlier in the same job. Co-authored-by: Cursor --- .github/workflows/deploy.yml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index d97b33a082b4..ecb729deb27c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -86,9 +86,23 @@ jobs: - 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 }} with: script: | - const commitMessages = (context.payload.commits || []).map((commit) => commit.message); + let commitMessages; + if (context.payload.commits) { + // push trigger: commits are in the payload + commitMessages = context.payload.commits.map((commit) => commit.message); + } else { + // workflow_dispatch: payload has no commits array, so fetch the HEAD commit message directly + const { data: commit } = await github.rest.git.getCommit({ + owner: context.repo.owner, + repo: context.repo.repo, + commit_sha: process.env.HEAD_COMMIT_SHA, + }); + commitMessages = [commit.message]; + } const isCherryPick = commitMessages.some((message) => /.*\(cherry-picked to .* by .*\)$/.test(message)); console.log('Is cherry pick?', isCherryPick); core.setOutput( From 450d4c641675af6796956f9612de5a4332d58a3d Mon Sep 17 00:00:00 2001 From: rory Date: Mon, 11 May 2026 13:46:29 -0700 Subject: [PATCH 04/11] Address Codex review comments P2: Fix concurrency group so push and manual dispatch runs for the same environment share the same group key and cancel each other correctly. Previously push triggers used refs/heads/staging while dispatches used staging, so cancel-in-progress didn't apply across trigger types. P1: Pass DEPLOY_SHA as a REF input to createDeployChecklist.yml so its checkout always uses the staging branch HEAD rather than whatever ref dispatched the calling workflow. Add a REF input to that workflow's workflow_call trigger and use it in the checkout step. Co-authored-by: Cursor --- .github/workflows/createDeployChecklist.yml | 7 +++++++ .github/workflows/deploy.yml | 4 +++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/createDeployChecklist.yml b/.github/workflows/createDeployChecklist.yml index 6e36527f9bb7..f5c13f2ede3d 100644 --- a/.github/workflows/createDeployChecklist.yml +++ b/.github/workflows/createDeployChecklist.yml @@ -2,6 +2,11 @@ name: Create or update deploy checklist on: workflow_call: + inputs: + REF: + description: 'Git ref (branch, tag, or SHA) to check out. Defaults to the caller commit SHA.' + type: string + required: false workflow_dispatch: jobs: @@ -11,6 +16,8 @@ jobs: - name: Checkout # v6 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + ref: ${{ inputs.REF || github.sha }} - name: Setup Node uses: ./.github/actions/composite/setupNode diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ecb729deb27c..025227491bf5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -18,7 +18,7 @@ env: DEPLOY_ENV: ${{ inputs.ENVIRONMENT || (github.ref == 'refs/heads/production' && 'production' || 'staging') }} concurrency: - group: ${{ github.workflow }}-${{ inputs.ENVIRONMENT || github.ref }} + group: ${{ github.workflow }}-${{ inputs.ENVIRONMENT || (github.ref == 'refs/heads/production' && 'production' || 'staging') }} cancel-in-progress: true jobs: @@ -124,6 +124,8 @@ jobs: uses: ./.github/workflows/createDeployChecklist.yml if: ${{ needs.prep.outputs.DEPLOY_ENV == 'staging' }} needs: prep + with: + REF: ${{ needs.prep.outputs.DEPLOY_SHA }} secrets: inherit androidBuild: From a6fe936a407056fa0a2633e7cfe562fbad0cad00 Mon Sep 17 00:00:00 2001 From: rory Date: Mon, 11 May 2026 14:01:39 -0700 Subject: [PATCH 05/11] Fix isCherryPick for merge commits on workflow_dispatch When a cherry-pick conflict-resolution PR is merged, GitHub creates a merge commit at HEAD. The cherry-picked commit carrying the "(cherry-picked to ... by ...)" marker sits under the merge, not at HEAD itself, so the previous single-commit API lookup always returned false for this case. For workflow_dispatch, detect merge commits (parents.length > 1) and use compareCommits between the first parent and HEAD to retrieve all merged-in commits, which includes the cherry-pick commit with the marker. Co-authored-by: Cursor --- .github/workflows/deploy.yml | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 025227491bf5..81a91989a02c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -92,16 +92,33 @@ jobs: script: | let commitMessages; if (context.payload.commits) { - // push trigger: commits are in the payload + // 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, so fetch the HEAD commit message directly - const { data: commit } = await github.rest.git.getCommit({ + // workflow_dispatch: payload has no commits array. + // For a conflict-resolution cherry-pick, GitHub creates a merge commit at HEAD whose first + // parent is the branch before the merge. The cherry-picked commit (which carries the + // "(cherry-picked to ... by ...)" marker) sits under the merge commit, not at HEAD itself. + // We therefore compare the first parent against HEAD to get all commits that were merged in. + const headSha = process.env.HEAD_COMMIT_SHA; + const { data: headCommit } = await github.rest.git.getCommit({ owner: context.repo.owner, repo: context.repo.repo, - commit_sha: process.env.HEAD_COMMIT_SHA, + commit_sha: headSha, }); - commitMessages = [commit.message]; + if (headCommit.parents.length > 1) { + // Merge commit: get all commits between first parent and HEAD + const { data: comparison } = await github.rest.repos.compareCommits({ + owner: context.repo.owner, + repo: context.repo.repo, + base: headCommit.parents[0].sha, + head: headSha, + }); + commitMessages = [headCommit.message, ...comparison.commits.map((c) => c.commit.message)]; + } else { + // Linear commit: only check this commit's message + commitMessages = [headCommit.message]; + } } const isCherryPick = commitMessages.some((message) => /.*\(cherry-picked to .* by .*\)$/.test(message)); console.log('Is cherry pick?', isCherryPick); From b270aa607d191ec00135d1fde496aa9ef1867d94 Mon Sep 17 00:00:00 2001 From: rory Date: Mon, 11 May 2026 14:10:20 -0700 Subject: [PATCH 06/11] Make tag creation idempotent for manual re-triggers If a deploy fails after the tag has already been created and pushed, a manual re-trigger would immediately abort in prep because `git tag ` exits 128. Make tag creation idempotent: if the tag already exists at HEAD, skip creation; if it exists at a different commit, fail loudly to avoid deploying the wrong code. Co-authored-by: Cursor --- .github/workflows/deploy.yml | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 81a91989a02c..831a72d6088e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -75,11 +75,31 @@ jobs: - name: Create and push tag run: | - git tag ${{ steps.getTagName.outputs.TAG }} - git push origin --tags + # 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) + if git tag "$tag" 2>/dev/null; then + git push origin --tags + else + git fetch origin "refs/tags/$tag:refs/tags/$tag" 2>/dev/null || true + local existing_sha + existing_sha=$(git rev-parse "refs/tags/$tag" 2>/dev/null || echo "unknown") + 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 + fi + } + create_tag_idempotent ${{ steps.getTagName.outputs.TAG }} cd Mobile-Expensify - git tag ${{ steps.getTagName.outputs.TAG }} - git push origin --tags + 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 From 4222cb6789a43becad03c50e8a13646b12253096 Mon Sep 17 00:00:00 2001 From: rory Date: Mon, 11 May 2026 16:28:24 -0700 Subject: [PATCH 07/11] Use github.sha for push deploys, branch ref for manual dispatch For push-triggered runs, fall back to github.sha (the exact commit that triggered the push) rather than github.ref (the branch name). Using the branch name risks checking out a newer commit if the branch advances between the webhook firing and the checkout step, which would tag and deploy the wrong code on a re-run. For workflow_dispatch, inputs.ENVIRONMENT is set, so it is used as the branch ref to check out the target environment's branch tip as intended. Co-authored-by: Cursor --- .github/workflows/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 831a72d6088e..52c53b7955c6 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -40,7 +40,7 @@ jobs: # v6 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: - ref: ${{ inputs.ENVIRONMENT || github.ref }} + ref: ${{ inputs.ENVIRONMENT || github.sha }} token: ${{ secrets.OS_BOTIFY_TOKEN }} submodules: true From 789efcbd08a0a2f10f4c02462b01d7b3cca9c8c7 Mon Sep 17 00:00:00 2001 From: rory Date: Mon, 11 May 2026 21:38:37 -0700 Subject: [PATCH 08/11] Fix isCherryPick to scan full range since previous tag on dispatch For workflow_dispatch, HEAD alone misses the cherry-pick marker in two cases: 1. Linear push (Mobile-Expensify path): the marker is amended onto the version-bump commit; a final unmarked submodule-update commit is pushed on top, becoming HEAD. 2. Merge commit (conflict-resolution PR): the marker sits on an inner commit, not on the merge commit at HEAD. Replace the per-case merge-commit detection with a single approach that covers both: list the most recent prior tag on the same branch (staging or production) and use compareCommits(prevTag, HEAD). compareCommits follows all parents, so it catches markers in inner commits of both linear and merge-commit topologies. Filter by tag suffix to avoid false positives from cherry-picks that were part of a prior deploy on the same branch. Co-authored-by: Cursor --- .github/workflows/deploy.yml | 41 ++++++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 52c53b7955c6..e2d1d2808c94 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -108,6 +108,7 @@ jobs: uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: HEAD_COMMIT_SHA: ${{ steps.getDeploySHA.outputs.SHA }} + DEPLOY_TAG: ${{ steps.getTagName.outputs.TAG }} with: script: | let commitMessages; @@ -116,27 +117,45 @@ jobs: commitMessages = context.payload.commits.map((commit) => commit.message); } else { // workflow_dispatch: payload has no commits array. - // For a conflict-resolution cherry-pick, GitHub creates a merge commit at HEAD whose first - // parent is the branch before the merge. The cherry-picked commit (which carries the - // "(cherry-picked to ... by ...)" marker) sits under the merge commit, not at HEAD itself. - // We therefore compare the first parent against HEAD to get all commits that were merged in. + // 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 { data: headCommit } = await github.rest.git.getCommit({ + const currentTag = process.env.DEPLOY_TAG; + const isStaging = currentTag.endsWith('-staging'); + + const { data: tags } = await github.rest.repos.listTags({ owner: context.repo.owner, repo: context.repo.repo, - commit_sha: headSha, + per_page: 20, + }); + + // 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. + const prevTag = tags.find((t) => { + if (t.name === currentTag) return false; + return isStaging ? t.name.endsWith('-staging') : !t.name.endsWith('-staging'); }); - if (headCommit.parents.length > 1) { - // Merge commit: get all commits between first parent and HEAD + + if (prevTag) { const { data: comparison } = await github.rest.repos.compareCommits({ owner: context.repo.owner, repo: context.repo.repo, - base: headCommit.parents[0].sha, + base: prevTag.commit.sha, head: headSha, }); - commitMessages = [headCommit.message, ...comparison.commits.map((c) => c.commit.message)]; + commitMessages = comparison.commits.map((c) => c.commit.message); } else { - // Linear commit: only check this commit's message + // 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]; } } From ce4429c6168ad679cd9a77ba0e5c5534c130f024 Mon Sep 17 00:00:00 2001 From: rory Date: Mon, 11 May 2026 22:19:24 -0700 Subject: [PATCH 09/11] Fetch remote tag before local check in create_tag_idempotent actions/checkout uses fetch-depth=1 and fetch-tags=false, so a tag pushed by a prior run is absent from the local workspace. The previous logic tried git tag first: it would succeed locally, then git push would be rejected by the remote because the tag already exists there, and the idempotent recovery path was never reached. Fix: fetch the remote tag first. If it now exists locally, check whether it points to the expected HEAD SHA and skip or fail accordingly. Only create and push if the tag is truly absent from the remote. Co-authored-by: Cursor --- .github/workflows/deploy.yml | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e2d1d2808c94..275a0ca42f02 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -83,18 +83,24 @@ jobs: local tag="$1" local head_sha head_sha=$(git rev-parse HEAD) - if git tag "$tag" 2>/dev/null; then - git push origin --tags - else - git fetch origin "refs/tags/$tag:refs/tags/$tag" 2>/dev/null || true + # Fetch the tag from remote before checking locally. actions/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" 2>/dev/null || echo "unknown") + 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 }} From 0830282fb48adfbdb8b7c86142c315cf9f3846c0 Mon Sep 17 00:00:00 2001 From: rory Date: Mon, 11 May 2026 22:29:47 -0700 Subject: [PATCH 10/11] Paginate tag list when finding previous deploy tag A fixed page size of 20 isn't enough for production dispatches: if more than 20 staging tags have been created since the last production release, listTags returns only staging tags on the first page and prevTag is undefined, causing the code to fall back to HEAD only and miss the cherry-pick marker. Paginate with per_page=100 until a matching tag (same suffix) is found or all tags have been exhausted. Co-authored-by: Cursor --- .github/workflows/deploy.yml | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 275a0ca42f02..f1fee71ed8e5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -134,18 +134,27 @@ jobs: const currentTag = process.env.DEPLOY_TAG; const isStaging = currentTag.endsWith('-staging'); - const { data: tags } = await github.rest.repos.listTags({ - owner: context.repo.owner, - repo: context.repo.repo, - per_page: 20, - }); - - // 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. - const prevTag = tags.find((t) => { - if (t.name === currentTag) return false; - return isStaging ? t.name.endsWith('-staging') : !t.name.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({ From a46f2637b361c8cbfa3d65d870083fc86c7a9001 Mon Sep 17 00:00:00 2001 From: Rory Abraham <47436092+roryabraham@users.noreply.github.com> Date: Tue, 12 May 2026 14:09:08 -0700 Subject: [PATCH 11/11] Update .github/workflows/cherryPick.yml Co-authored-by: Francois Laithier --- .github/workflows/cherryPick.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/cherryPick.yml b/.github/workflows/cherryPick.yml index 82a46c27a2b4..86d4acfefabe 100644 --- a/.github/workflows/cherryPick.yml +++ b/.github/workflows/cherryPick.yml @@ -356,7 +356,8 @@ jobs: Note that you **must** test this PR, and both the author and reviewer checklist should be completed, just as if you were merging the PR to main. - **Important:** This PR should ideally be merged by a member of the [mobile-deployers](https://github.com/orgs/Expensify/teams/mobile-deployers) team. If it is merged by someone who is not a deployer, the staging deploy triggered by the merge will fail and will need to be manually re-triggered (not just retried) via the [deploy workflow](https://github.com/${{ github.repository }}/actions/workflows/deploy.yml). + > [!IMPORTANT] + > This PR should ideally be merged by a member of the [mobile-deployers](https://github.com/orgs/Expensify/teams/mobile-deployers) team. If it is merged by someone who is not a deployer, the staging deploy triggered by the merge will fail and will need to be manually re-triggered (not just retried) via the [deploy workflow](https://github.com/${{ github.repository }}/actions/workflows/deploy.yml). _Pro-tip:_ If this PR appears to have conflicts against the _${{ inputs.TARGET }}_ base, it means that the version on ${{ inputs.TARGET }} has been updated. The easiest thing to do if you see this is to close the PR and re-run the CP.