Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 13 additions & 20 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -769,14 +769,7 @@ 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"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this script is not used anymore, should we delete it?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

env:
HOST: ${{ vars.WEB_PRODUCTION_HOST }}
# The host is verified by webDeployWorker, which deploys after this job.

webDeployTestStaging:
name: Deploy Web to test-staging.new.expensify.com
Expand Down Expand Up @@ -836,11 +829,11 @@ jobs:
env:
HOST: https://test-staging.new.expensify.com

webDeployStagingWorker:
name: Deploy Web to the staging Cloudflare Worker
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]
if: ${{ always() && needs.webBuild.result == 'success' && needs.prep.outputs.DEPLOY_ENV == 'staging' }}
if: ${{ always() && needs.webBuild.result == 'success' }}
Comment thread
justinpersaud marked this conversation as resolved.
Resolved
runs-on: blacksmith-2vcpu-ubuntu-2404
steps:
- name: Checkout
Expand Down Expand Up @@ -876,7 +869,7 @@ jobs:
fi
aws configure set default.s3.preferred_transfer_client classic
# Re-uploads every file, so files that stay the same across builds don't hit the bucket's 60-day expiry while still in use.
aws s3 cp dist s3://new-expensify-web-assets-staging/ --recursive \
aws s3 cp dist "s3://new-expensify-web-assets-$DEPLOY_ENV/" --recursive \
--endpoint-url "https://$CLOUDFLARE_ACCOUNT_ID.r2.cloudflarestorage.com" \
--no-progress \
--exclude '*.br' --exclude '*.map'
Expand All @@ -900,7 +893,7 @@ jobs:

- name: Deploy to Cloudflare Workers
working-directory: web/cloudflare-worker
run: npx wrangler deploy --env staging
run: npx wrangler deploy --env "$DEPLOY_ENV"
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_WORKERS_DEPLOY_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
Expand All @@ -911,7 +904,7 @@ jobs:
APP_VERSION=$(jq -r .version < package.json)
./.github/scripts/verifyDeploy.sh "$HOST" "$APP_VERSION"
env:
HOST: ${{ vars.WEB_STAGING_HOST }}
HOST: ${{ env.DEPLOY_ENV == 'production' && vars.WEB_PRODUCTION_HOST || vars.WEB_STAGING_HOST }}

buildStorybook:
name: Build storybook docs
Expand Down Expand Up @@ -960,7 +953,7 @@ jobs:
iosSubmit,
webBuild,
webDeploy,
webDeployStagingWorker,
webDeployWorker,
victoryChartRendererBuild,
]
steps:
Expand All @@ -976,7 +969,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, webDeployStagingWorker]
needs: [androidBuild, androidUploadGooglePlay, androidSubmit, iosBuild, iosUploadTestflight, iosSubmit, webBuild, webDeploy, webDeployWorker]
steps:
- name: Check whether a newer deploy run exists
id: check
Expand Down Expand Up @@ -1015,7 +1008,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, webDeployStagingWorker, victoryChartRendererBuild]
needs: [androidBuild, androidUploadGooglePlay, androidSubmit, iosBuild, iosUploadTestflight, iosSubmit, webBuild, webDeploy, webDeployWorker, victoryChartRendererBuild]
if: ${{ always() }}
steps:
# Determine effective result for each platform
Expand Down Expand Up @@ -1050,11 +1043,11 @@ jobs:
echo "IOS_RESULT=${{ needs.iosUploadTestflight.result }}" >> "$GITHUB_OUTPUT"
fi

# Web: propagate build failure even when deploy is skipped. Staging is only deployed once its Worker is too.
# Web: propagate build failure even when deploy is skipped. Web 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"
elif [ "${{ needs.webDeployWorker.result }}" != "success" ]; then
echo "WEB_RESULT=${{ needs.webDeployWorker.result }}" >> "$GITHUB_OUTPUT"
else
echo "WEB_RESULT=${{ needs.webDeploy.result }}" >> "$GITHUB_OUTPUT"
fi
Expand Down
16 changes: 7 additions & 9 deletions web/cloudflare-worker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,14 +108,14 @@ The script diffs status, caching and security headers for a fixed set of paths.
| Environment | Worker | Route | CSP |
| --- | --- | --- | --- |
| `test-staging` | `test-staging-new-expensify-assets` | `test-staging.new.expensify.com/*` (managed in Cloudflare, not in `wrangler.jsonc`) | Staging |
| `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 |
| `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 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.
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 `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.
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.

`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.
`test-staging` serves a staging build on a hostname nobody else uses.

To deploy one by hand, put a staging build in `dist/` and run:

Expand All @@ -127,11 +127,9 @@ CI also adds Storybook to `dist/docs` before deploying, so it is served at `/doc

### Files from previous builds

Each Worker version only contains the files uploaded with it, but a tab still running an older build keeps requesting that build's chunks after a deploy. So every staging deploy also uploads its build (without `*.br`, `*.map` and Storybook) to the R2 bucket `new-expensify-web-assets-staging`. Every file is re-uploaded each time, so a file still used by recent builds never reaches the bucket's 60-day expiry. When the current build doesn't have a file that a script or image requests, the `test-staging` and `staging` Workers look it up in that bucket (the `PREVIOUS_BUILDS` binding) and return `404` only if it isn't there either. The current build always takes priority, and navigations never read from the bucket.
Each Worker version only contains the files uploaded with it, but a tab still running an older build keeps requesting that build's chunks after a deploy. So every staging and production deploy also uploads its build (without `*.br`, `*.map` and Storybook) to that environment's R2 bucket, `new-expensify-web-assets-staging` or `new-expensify-web-assets-production`. Every file is re-uploaded each time, so a file still used by recent builds never reaches the bucket's 60-day expiry. When the current build doesn't have a file that a script or image requests, the Worker looks it up in its environment's bucket (the `PREVIOUS_BUILDS` binding; `test-staging` uses the staging one) and returns `404` only if it isn't there either. The current build always takes priority, and navigations never read from the bucket.

The bucket and its 60-day lifecycle rule are managed in Terraform. The upload uses `CLOUDFLARE_R2_WEB_ASSETS_ACCESS_KEY_ID` and `CLOUDFLARE_R2_WEB_ASSETS_SECRET_ACCESS_KEY`, an R2 token that can only read and write that bucket. `wrangler dev` uses an empty local bucket.

Staging and production deploys will run `wrangler deploy --env staging|production` from CI, with `CLOUDFLARE_ACCOUNT_ID` and a scoped `CLOUDFLARE_API_TOKEN` supplied as environment secrets.
The buckets and their 60-day lifecycle rule are managed in Terraform. The upload uses `CLOUDFLARE_R2_WEB_ASSETS_ACCESS_KEY_ID` and `CLOUDFLARE_R2_WEB_ASSETS_SECRET_ACCESS_KEY`, an R2 token that can only read and write those buckets. `wrangler dev` uses an empty local bucket.

Workers Logs and traces are sampled in `staging` and `production` (`head_sampling_rate`), because the Worker runs for every asset request. `test-staging` logs and traces every request.

Expand Down
22 changes: 17 additions & 5 deletions web/cloudflare-worker/tests/worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,10 +335,12 @@ describe('test-staging worker', () => {
});

describe('production worker', () => {
const {server, cleanup} = createWorkerHarness('production');
const {server, addPreviousBuildFiles, cleanup} = createWorkerHarness('production');
const PREVIOUS_CHUNK_PATH = '/main-aaaaaaaaaaaaaaaa.bundle.js';

before(async () => {
await server.listen();
await addPreviousBuildFiles([{path: PREVIOUS_CHUNK_PATH.slice(1), contents: 'window.previousBuildChunk = true;', contentType: 'text/javascript'}]);
});

after(async () => {
Expand All @@ -357,12 +359,22 @@ describe('production worker', () => {
assertTemplateInlineScriptsAllowed(csp);
});

test('returns 404 for a missing chunk without a previous builds bucket', async () => {
// Given production has no PREVIOUS_BUILDS binding yet
// When a chunk the current build doesn't have is requested
test('serves a chunk the current build no longer has from a previous build', async () => {
// Given a tab still running an older production build, whose chunk is in the production bucket
// When the tab lazy-loads it after a newer deploy
const response = await server.fetch(PREVIOUS_CHUNK_PATH);

// Then it loads from the previous build
assert.equal(response.status, 200);
assert.equal(await response.text(), 'window.previousBuildChunk = true;');
});

test('returns 404 for a chunk no build has', async () => {
// Given a chunk that is neither in the current build nor in the bucket
// When it is requested
const response = await server.fetch('/main-ffffffffffffffff.bundle.js');

// Then it is a 404 rather than an error from the missing binding
// Then it is a 404, so the tab gets a ChunkLoadError and recovers
assert.equal(response.status, 404);
});
});
6 changes: 4 additions & 2 deletions web/cloudflare-worker/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,14 @@
// Every response needs security headers, and HTML needs a per-request CSP nonce, including SPA navigations.
"run_worker_first": true,
},
// Files from previous staging builds, served when a tab running an older build requests a file the current build
// Files from previous builds, served when a tab running an older build requests a file the current build
// doesn't have. `wrangler dev` uses an empty local bucket.
"r2_buckets": [{"binding": "PREVIOUS_BUILDS", "bucket_name": "new-expensify-web-assets-staging"}],
"vars": {
"ENVIRONMENT": "staging",
},
// Routes are managed in Cloudflare, not here: syncing routes needs zone access, and the CI deploy token can only
// edit the test-staging and staging Workers.
// edit these Workers.
"env": {
// Serves a staging build on test-staging.new.expensify.com, a hostname nobody else uses.
"test-staging": {
Expand All @@ -61,8 +61,10 @@
"ENVIRONMENT": "staging",
},
},
// Serves nothing on new.expensify.com until new.expensify.com/* is routed to this Worker.
"production": {
"name": "new-expensify-assets",
"r2_buckets": [{"binding": "PREVIOUS_BUILDS", "bucket_name": "new-expensify-web-assets-production"}],
"observability": {
"enabled": true,
"head_sampling_rate": 0.01,
Expand Down
Loading