From 372fe2ba7302eaf27224fa14175d7a1d19ec0f36 Mon Sep 17 00:00:00 2001 From: Justin Persaud Date: Mon, 19 Oct 2026 14:59:08 -0400 Subject: [PATCH 1/2] Require the staging Cloudflare Worker deploy and verify staging there Once staging.new.expensify.com is routed to the staging Worker, the S3 job's version check would race the Worker deploy, and a failed Worker deploy would leave the old build live without failing the staging deploy. The staging Worker now deploys in its own job after the S3 upload, verifies the staging host, and counts towards the web deploy result. test-staging stays an optional trial job, and the S3 job only verifies production. --- .github/workflows/deploy.yml | 90 +++++++++++++++++++++++++++++------- 1 file changed, 73 insertions(+), 17 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5dbc319b501f..d2cebadd8ba8 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -769,24 +769,21 @@ jobs: CF_API_KEY: ${{ secrets.CLOUDFLARE_TOKEN }} HOST: ${{ env.DEPLOY_ENV == 'production' && vars.WEB_PRODUCTION_HOST || vars.WEB_STAGING_HOST }} + # The staging host is verified by webDeployStagingWorker, which deploys after this job. - name: Verify deploy + if: ${{ env.DEPLOY_ENV == 'production' }} 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 }} + HOST: ${{ vars.WEB_PRODUCTION_HOST }} - webDeployCloudflareWorkers: - name: Deploy Web to Cloudflare Workers (${{ matrix.environment }}) + webDeployTestStaging: + name: Deploy Web to test-staging.new.expensify.com needs: [prep, webBuild, buildStorybook] if: ${{ always() && needs.webBuild.result == 'success' && needs.prep.outputs.DEPLOY_ENV == 'staging' }} # Trial hosting for the web build, so a failure here must not fail the staging deploy. continue-on-error: true - strategy: - fail-fast: false - matrix: - # `staging` serves no traffic until staging.new.expensify.com is routed to its Worker in Cloudflare. - environment: [test-staging, staging] runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout @@ -812,10 +809,65 @@ jobs: - name: Extract web build run: tar -xzf "${{ needs.webBuild.outputs.TAR_FILENAME }}" - # The Workers serve these to tabs still running this build after later deploys. Both Workers read the same bucket, - # so only one matrix leg uploads. + - name: Download storybook docs artifact + continue-on-error: true + # v7 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 + with: + name: storybook-docs-artifact + path: ./dist/docs + + - name: Install Cloudflare Worker dependencies + working-directory: web/cloudflare-worker + run: npm ci + + - name: Deploy to Cloudflare Workers + working-directory: web/cloudflare-worker + run: npx wrangler deploy --env test-staging + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_WORKERS_DEPLOY_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + WRANGLER_SEND_METRICS: false + + - name: Verify deploy + run: | + APP_VERSION=$(jq -r .version < package.json) + ./.github/scripts/verifyDeploy.sh "$HOST" "$APP_VERSION" + env: + HOST: https://test-staging.new.expensify.com + + webDeployStagingWorker: + name: Deploy Web to the staging 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] + if: ${{ always() && needs.webBuild.result == 'success' && needs.prep.outputs.DEPLOY_ENV == 'staging' }} + runs-on: blacksmith-2vcpu-ubuntu-2404 + steps: + - name: Checkout + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 + with: + ref: ${{ needs.prep.outputs.DEPLOY_SHA }} + + - name: Setup Node + # v6.1.0 + uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f + with: + node-version-file: '.nvmrc' + cache: npm + cache-dependency-path: web/cloudflare-worker/package-lock.json + + - 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 -xzf "${{ needs.webBuild.outputs.TAR_FILENAME }}" + + # The Workers serve these to tabs still running this build after later deploys. - name: Upload web build to the previous builds bucket - if: ${{ matrix.environment == 'staging' }} continue-on-error: true run: | if [[ -z "$AWS_ACCESS_KEY_ID" ]]; then @@ -848,17 +900,18 @@ jobs: - name: Deploy to Cloudflare Workers working-directory: web/cloudflare-worker - run: npx wrangler deploy --env "${{ matrix.environment }}" + run: npx wrangler deploy --env staging env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_WORKERS_DEPLOY_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} WRANGLER_SEND_METRICS: false - name: Verify deploy - if: ${{ matrix.environment == 'test-staging' }} run: | APP_VERSION=$(jq -r .version < package.json) - ./.github/scripts/verifyDeploy.sh https://test-staging.new.expensify.com "$APP_VERSION" + ./.github/scripts/verifyDeploy.sh "$HOST" "$APP_VERSION" + env: + HOST: ${{ vars.WEB_STAGING_HOST }} buildStorybook: name: Build storybook docs @@ -907,6 +960,7 @@ jobs: iosSubmit, webBuild, webDeploy, + webDeployStagingWorker, victoryChartRendererBuild, ] steps: @@ -922,7 +976,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] + needs: [androidBuild, androidUploadGooglePlay, androidSubmit, iosBuild, iosUploadTestflight, iosSubmit, webBuild, webDeploy, webDeployStagingWorker] steps: - name: Check whether a newer deploy run exists id: check @@ -961,7 +1015,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, victoryChartRendererBuild] + needs: [androidBuild, androidUploadGooglePlay, androidSubmit, iosBuild, iosUploadTestflight, iosSubmit, webBuild, webDeploy, webDeployStagingWorker, victoryChartRendererBuild] if: ${{ always() }} steps: # Determine effective result for each platform @@ -996,9 +1050,11 @@ jobs: echo "IOS_RESULT=${{ needs.iosUploadTestflight.result }}" >> "$GITHUB_OUTPUT" fi - # Web: propagate build failure even when deploy is skipped + # Web: propagate build failure even when deploy is skipped. Staging is only deployed once its Worker is too. if [ "${{ needs.webBuild.result }}" == "failure" ]; then echo "WEB_RESULT=failure" >> "$GITHUB_OUTPUT" + elif [ "${{ env.DEPLOY_ENV }}" == "staging" ] && [ "${{ needs.webDeployStagingWorker.result }}" != "success" ]; then + echo "WEB_RESULT=${{ needs.webDeployStagingWorker.result }}" >> "$GITHUB_OUTPUT" else echo "WEB_RESULT=${{ needs.webDeploy.result }}" >> "$GITHUB_OUTPUT" fi From 69dad0ae965f42da8074c0ab6f8ad295e08fcab5 Mon Sep 17 00:00:00 2001 From: Justin Persaud Date: Mon, 19 Oct 2026 14:59:19 -0400 Subject: [PATCH 2/2] Document the required staging Worker deploy --- web/cloudflare-worker/README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/cloudflare-worker/README.md b/web/cloudflare-worker/README.md index e57f421bdae3..bcdb5556a404 100644 --- a/web/cloudflare-worker/README.md +++ b/web/cloudflare-worker/README.md @@ -111,7 +111,9 @@ The script diffs status, caching and security headers for a fixed set of paths. | `staging` | `staging-new-expensify-assets` | None until switchover, then `staging.new.expensify.com/*` (managed in Cloudflare, not in `wrangler.jsonc`) | Staging | | `production` | `new-expensify-assets` | None until switchover, then `new.expensify.com/*` | Production | -Every staging deploy also deploys the same build to the `test-staging` and `staging` Workers (the `webDeployCloudflareWorkers` job in `.github/workflows/deploy.yml`). It uses `CLOUDFLARE_WORKERS_DEPLOY_TOKEN`, which can only edit those two Workers. That token can't read zones, so routes aren't in `wrangler.jsonc`: wrangler would try to sync them and fail. A failure there does not fail the staging deploy. +Every staging deploy also deploys the same build to the `staging` Worker (the `webDeployStagingWorker` job in `.github/workflows/deploy.yml`) and the `test-staging` Worker (`webDeployTestStaging`). Both use `CLOUDFLARE_WORKERS_DEPLOY_TOKEN`, which can only edit those two Workers. That token can't read zones, so routes aren't in `wrangler.jsonc`: wrangler would try to sync them and fail. + +The `staging` deploy runs after the S3 upload and is required: if it fails, or `staging.new.expensify.com/version.json` doesn't report the new version, the staging deploy fails. 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. `staging` serves nothing until `staging.new.expensify.com/*` is pointed at its Worker in Cloudflare.