From eb26fdf2a54c1eb17c640fe3a55c77033991b02a Mon Sep 17 00:00:00 2001 From: Justin Persaud Date: Fri, 23 Oct 2026 11:15:01 -0400 Subject: [PATCH 1/4] Make the Cloudflare Worker job the only web deploy new.expensify.com and staging.new.expensify.com are served by the Cloudflare Worker, so the webDeploy job no longer changes what users get, and its zone cache purge doesn't reach Worker responses: a new Worker version takes effect immediately. The deploy's web result now comes from the Worker job alone. --- .github/workflows/deploy.yml | 84 +++--------------------------------- 1 file changed, 6 insertions(+), 78 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5fb82501a3d4..b1ef40a26d96 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -703,74 +703,6 @@ jobs: release-tag: ${{ needs.prep.outputs.TAG }} secrets: inherit - webDeploy: - name: Deploy Web to S3 - needs: [prep, webBuild, buildStorybook] - if: ${{ always() && needs.webBuild.result == 'success' }} - # Preserve the default token scopes while adding OIDC access - permissions: write-all - runs-on: blacksmith-4vcpu-ubuntu-2404 - steps: - - name: Checkout - uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - 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: - role-to-assume: arn:aws:iam::949552126497:role/app-${{ env.DEPLOY_ENV }}-web-deploy-gh-actions - role-session-name: gh-${{ github.run_id }}-${{ github.run_attempt }} - aws-region: us-east-1 - - - name: Deploy to S3 - run: | - aws s3 cp --recursive --acl public-read --exclude "*.br" "$GITHUB_WORKSPACE"/dist ${{ env.S3_BUCKET }}/ - - for PAIR in js:text/javascript css:text/css html:text/html svg:image/svg+xml wasm:application/wasm ttf:font/ttf; do - EXT="${PAIR%%:*}" - CONTENT_TYPE="${PAIR#*:}" - aws s3 cp --recursive --acl public-read --exclude "*" --include "*.${EXT}.br" \ - --content-encoding br --content-type "$CONTENT_TYPE" \ - "$GITHUB_WORKSPACE"/dist ${{ env.S3_BUCKET }}/ - done - - 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 }} - - # The host is verified by webDeployWorker, which deploys after this job. - webDeployTestStaging: name: Deploy Web to test-staging.new.expensify.com needs: [prep, webBuild, buildStorybook] @@ -831,8 +763,7 @@ jobs: webDeployWorker: name: Deploy Web to the Cloudflare Worker - # After webDeploy, so the S3 copy of this build is in place before the Worker's version check. - needs: [prep, webBuild, buildStorybook, webDeploy] + needs: [prep, webBuild, buildStorybook] if: ${{ always() && needs.webBuild.result == 'success' }} runs-on: blacksmith-2vcpu-ubuntu-2404 steps: @@ -952,7 +883,6 @@ jobs: iosUploadApplause, iosSubmit, webBuild, - webDeploy, webDeployWorker, victoryChartRendererBuild, ] @@ -969,7 +899,7 @@ jobs: 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, webDeployWorker] + needs: [androidBuild, androidUploadGooglePlay, androidSubmit, iosBuild, iosUploadTestflight, iosSubmit, webBuild, webDeployWorker] steps: - name: Check whether a newer deploy run exists id: check @@ -1008,7 +938,7 @@ jobs: 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, webDeployWorker, victoryChartRendererBuild] + needs: [androidBuild, androidUploadGooglePlay, androidSubmit, iosBuild, iosUploadTestflight, iosSubmit, webBuild, webDeployWorker, victoryChartRendererBuild] if: ${{ always() }} steps: # Determine effective result for each platform @@ -1043,13 +973,11 @@ jobs: echo "IOS_RESULT=${{ needs.iosUploadTestflight.result }}" >> "$GITHUB_OUTPUT" fi - # Web: propagate build failure even when deploy is skipped. Web is only deployed once its Worker is too. + # Web: propagate build failure even when deploy is skipped. if [ "${{ needs.webBuild.result }}" == "failure" ]; then echo "WEB_RESULT=failure" >> "$GITHUB_OUTPUT" - elif [ "${{ needs.webDeployWorker.result }}" != "success" ]; then - echo "WEB_RESULT=${{ needs.webDeployWorker.result }}" >> "$GITHUB_OUTPUT" else - echo "WEB_RESULT=${{ needs.webDeploy.result }}" >> "$GITHUB_OUTPUT" + echo "WEB_RESULT=${{ needs.webDeployWorker.result }}" >> "$GITHUB_OUTPUT" fi - name: Check deployment success on at least one platform @@ -1340,7 +1268,7 @@ jobs: 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] + needs: [prep, androidUploadGooglePlay, androidSubmit, iosUploadTestflight, iosSubmit, webDeployWorker, checkDeploymentSuccess, victoryChartRendererBuild, createRelease] steps: - name: 'Announces the deploy in the #announce Slack room' uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3 From 15388748a3fbdccb992d5a596400baf140d86bd8 Mon Sep 17 00:00:00 2001 From: Justin Persaud Date: Fri, 23 Oct 2026 11:15:13 -0400 Subject: [PATCH 2/4] Use the current web deploy job name in the staging build check tests --- tests/tooling/verifyStagingBuildsSucceeded.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/tooling/verifyStagingBuildsSucceeded.test.ts b/tests/tooling/verifyStagingBuildsSucceeded.test.ts index 77306e79e9d4..9187c89a443d 100644 --- a/tests/tooling/verifyStagingBuildsSucceeded.test.ts +++ b/tests/tooling/verifyStagingBuildsSucceeded.test.ts @@ -41,7 +41,7 @@ function stagingDeployJobs(overrides: Array<[string, JobConclusion | null]> = [] [IOS_BUILD, 'success'], [IOS_UPLOAD, 'success'], ['Build Web / Build Web', 'success'], - ['Deploy Web to S3', 'success'], + ['Deploy Web to the Cloudflare Worker', 'success'], // Production-only, skipped on every staging deploy. ['Submit Android for production rollout', 'skipped'], @@ -188,7 +188,7 @@ describe('verifyStagingBuildsSucceeded', () => { test('ignores a failed web deploy and the continue-on-error uploads', async () => { mockListJobs.mockResolvedValue( stagingDeployJobs([ - ['Deploy Web to S3', 'failure'], + ['Deploy Web to the Cloudflare Worker', 'failure'], ['Upload iOS to BrowserStack', 'failure'], ['Upload Android to Applause', 'failure'], ]), From 1a0326ae6e797bd3f363bdbd4fb8c89eaaaf29b3 Mon Sep 17 00:00:00 2001 From: Justin Persaud Date: Fri, 23 Oct 2026 11:15:16 -0400 Subject: [PATCH 3/4] Document that the Worker deploy is the only web deploy --- web/cloudflare-worker/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/cloudflare-worker/README.md b/web/cloudflare-worker/README.md index 34887a303e78..bfec55d57e0e 100644 --- a/web/cloudflare-worker/README.md +++ b/web/cloudflare-worker/README.md @@ -115,7 +115,7 @@ The script diffs status, caching and security headers for a fixed set of paths. Every staging and production deploy also deploys the same build to that environment's Worker (the `webDeployWorker` job in `.github/workflows/deploy.yml`). Staging deploys also deploy it to the `test-staging` Worker (`webDeployTestStaging`). They use `CLOUDFLARE_WORKERS_DEPLOY_TOKEN`, which can only edit these three Workers. That token can't read zones, so routes aren't in `wrangler.jsonc`: wrangler would try to sync them and fail. -The Worker deploy runs after `webDeploy` and is required: if it fails, or `WEB_STAGING_HOST` or `WEB_PRODUCTION_HOST` doesn't report the new version in `/version.json`, the deploy fails. The version is checked there because both jobs have deployed by then, whichever one serves the host. The `test-staging` deploy is a trial, so its failures don't affect the staging deploy. +The Worker deploy is required: if it fails, or `WEB_STAGING_HOST` or `WEB_PRODUCTION_HOST` doesn't report the new version in `/version.json`, the deploy fails. A new Worker version takes effect immediately, so no cache needs purging. The `test-staging` deploy is a trial, so its failures don't affect the staging deploy. `test-staging` serves a staging build on a hostname nobody else uses. From 7b49af7bbd2fa7f5ed20008a7757c6261674b469 Mon Sep 17 00:00:00 2001 From: Justin Persaud Date: Sat, 24 Oct 2026 09:16:51 -0400 Subject: [PATCH 4/4] Rename webDeployWorker to webDeploy It is the only web deploy job now. --- .github/workflows/deploy.yml | 12 ++++++------ web/cloudflare-worker/README.md | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b1ef40a26d96..703f28d925d1 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -761,7 +761,7 @@ jobs: env: HOST: https://test-staging.new.expensify.com - webDeployWorker: + webDeploy: name: Deploy Web to the Cloudflare Worker needs: [prep, webBuild, buildStorybook] if: ${{ always() && needs.webBuild.result == 'success' }} @@ -883,7 +883,7 @@ jobs: iosUploadApplause, iosSubmit, webBuild, - webDeployWorker, + webDeploy, victoryChartRendererBuild, ] steps: @@ -899,7 +899,7 @@ jobs: 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, webDeployWorker] + needs: [androidBuild, androidUploadGooglePlay, androidSubmit, iosBuild, iosUploadTestflight, iosSubmit, webBuild, webDeploy] steps: - name: Check whether a newer deploy run exists id: check @@ -938,7 +938,7 @@ jobs: 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, webDeployWorker, victoryChartRendererBuild] + needs: [androidBuild, androidUploadGooglePlay, androidSubmit, iosBuild, iosUploadTestflight, iosSubmit, webBuild, webDeploy, victoryChartRendererBuild] if: ${{ always() }} steps: # Determine effective result for each platform @@ -977,7 +977,7 @@ jobs: if [ "${{ needs.webBuild.result }}" == "failure" ]; then echo "WEB_RESULT=failure" >> "$GITHUB_OUTPUT" else - echo "WEB_RESULT=${{ needs.webDeployWorker.result }}" >> "$GITHUB_OUTPUT" + echo "WEB_RESULT=${{ needs.webDeploy.result }}" >> "$GITHUB_OUTPUT" fi - name: Check deployment success on at least one platform @@ -1268,7 +1268,7 @@ jobs: 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, webDeployWorker, checkDeploymentSuccess, victoryChartRendererBuild, createRelease] + 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 diff --git a/web/cloudflare-worker/README.md b/web/cloudflare-worker/README.md index bfec55d57e0e..c3d15533acaf 100644 --- a/web/cloudflare-worker/README.md +++ b/web/cloudflare-worker/README.md @@ -113,7 +113,7 @@ The script diffs status, caching and security headers for a fixed set of paths. | `staging` | `staging-new-expensify-assets` | `staging.new.expensify.com/*` (managed in Cloudflare, not in `wrangler.jsonc`) | Staging | | `production` | `new-expensify-assets` | None until switchover, then `new.expensify.com/*` (managed in Cloudflare, not in `wrangler.jsonc`) | Production | -Every staging and production deploy also deploys the same build to that environment's Worker (the `webDeployWorker` job in `.github/workflows/deploy.yml`). Staging deploys also deploy it to the `test-staging` Worker (`webDeployTestStaging`). They use `CLOUDFLARE_WORKERS_DEPLOY_TOKEN`, which can only edit these three Workers. That token can't read zones, so routes aren't in `wrangler.jsonc`: wrangler would try to sync them and fail. +Every staging and production deploy also deploys the same build to that environment's Worker (the `webDeploy` job in `.github/workflows/deploy.yml`). Staging deploys also deploy it to the `test-staging` Worker (`webDeployTestStaging`). They use `CLOUDFLARE_WORKERS_DEPLOY_TOKEN`, which can only edit these three Workers. That token can't read zones, so routes aren't in `wrangler.jsonc`: wrangler would try to sync them and fail. The Worker deploy is required: if it fails, or `WEB_STAGING_HOST` or `WEB_PRODUCTION_HOST` doesn't report the new version in `/version.json`, the deploy fails. A new Worker version takes effect immediately, so no cache needs purging. The `test-staging` deploy is a trial, so its failures don't affect the staging deploy.