3a3248a636
This PR introduces a new Cloudflare worker for health checks. At the moment the worker only translates Updown webhooks into Discord webhooks. In the future we can teach this worker to check more things. ### Change Type - [x] `internal` — Any other changes that don't affect the published package --------- Co-authored-by: Steve Ruiz <steveruizok@gmail.com>
528 lines
16 KiB
TypeScript
528 lines
16 KiB
TypeScript
import * as github from '@actions/github'
|
|
import { GetObjectCommand, ListObjectsV2Command, S3Client } from '@aws-sdk/client-s3'
|
|
import { Upload } from '@aws-sdk/lib-storage'
|
|
import assert from 'assert'
|
|
import { execSync } from 'child_process'
|
|
import { appendFileSync, existsSync, readdirSync, writeFileSync } from 'fs'
|
|
import path, { join } from 'path'
|
|
import { PassThrough } from 'stream'
|
|
import tar from 'tar'
|
|
import { exec } from './lib/exec'
|
|
import { makeEnv } from './lib/makeEnv'
|
|
import { nicelog } from './lib/nicelog'
|
|
|
|
const worker = path.relative(process.cwd(), path.resolve(__dirname, '../apps/dotcom-worker'))
|
|
const healthWorker = path.relative(process.cwd(), path.resolve(__dirname, '../apps/health-worker'))
|
|
const assetUpload = path.relative(
|
|
process.cwd(),
|
|
path.resolve(__dirname, '../apps/dotcom-asset-upload')
|
|
)
|
|
const dotcom = path.relative(process.cwd(), path.resolve(__dirname, '../apps/dotcom'))
|
|
|
|
// Do not use `process.env` directly in this script. Add your variable to `makeEnv` and use it via
|
|
// `env` instead. This makes sure that all required env vars are present.
|
|
const env = makeEnv([
|
|
'APP_ORIGIN',
|
|
'ASSET_UPLOAD',
|
|
'CLOUDFLARE_ACCOUNT_ID',
|
|
'CLOUDFLARE_API_TOKEN',
|
|
'DISCORD_DEPLOY_WEBHOOK_URL',
|
|
'DISCORD_HEALTH_WEBHOOK_URL',
|
|
'HEALTH_WORKER_UPDOWN_WEBHOOK_PATH',
|
|
'GC_MAPS_API_KEY',
|
|
'RELEASE_COMMIT_HASH',
|
|
'SENTRY_AUTH_TOKEN',
|
|
'SENTRY_DSN',
|
|
'SUPABASE_LITE_ANON_KEY',
|
|
'SUPABASE_LITE_URL',
|
|
'TLDRAW_ENV',
|
|
'VERCEL_PROJECT_ID',
|
|
'VERCEL_ORG_ID',
|
|
'VERCEL_TOKEN',
|
|
'WORKER_SENTRY_DSN',
|
|
'MULTIPLAYER_SERVER',
|
|
'GH_TOKEN',
|
|
'R2_ACCESS_KEY_ID',
|
|
'R2_ACCESS_KEY_SECRET',
|
|
])
|
|
|
|
const githubPrNumber = process.env.GITHUB_REF?.match(/refs\/pull\/(\d+)\/merge/)?.[1]
|
|
function getPreviewId() {
|
|
if (env.TLDRAW_ENV !== 'preview') return undefined
|
|
if (githubPrNumber) return `pr-${githubPrNumber}`
|
|
return process.env.TLDRAW_PREVIEW_ID ?? undefined
|
|
}
|
|
const previewId = getPreviewId()
|
|
|
|
if (env.TLDRAW_ENV === 'preview' && !previewId) {
|
|
throw new Error(
|
|
'If running preview deploys from outside of a PR action, TLDRAW_PREVIEW_ID env var must be set'
|
|
)
|
|
}
|
|
const sha =
|
|
// if the event is 'pull_request', github.context.sha is an ephemeral merge commit
|
|
// while the actual commit we want to create the deployment for is the 'head' of the PR.
|
|
github.context.eventName === 'pull_request'
|
|
? github.context.payload.pull_request?.head.sha
|
|
: github.context.sha
|
|
|
|
const sentryReleaseName = `${env.TLDRAW_ENV}-${previewId ? previewId + '-' : ''}-${sha}`
|
|
|
|
async function main() {
|
|
assert(
|
|
env.TLDRAW_ENV === 'staging' || env.TLDRAW_ENV === 'production' || env.TLDRAW_ENV === 'preview',
|
|
'TLDRAW_ENV must be staging or production or preview'
|
|
)
|
|
|
|
await discordMessage(`--- **${env.TLDRAW_ENV} deploy pre-flight** ---`)
|
|
|
|
await discordStep('[1/7] setting up deploy', async () => {
|
|
// make sure the tldraw .css files are built:
|
|
await exec('yarn', ['lazy', 'prebuild'])
|
|
|
|
// link to vercel and supabase projects:
|
|
await vercelCli('link', ['--project', env.VERCEL_PROJECT_ID])
|
|
})
|
|
|
|
// deploy pre-flight steps:
|
|
// 1. get the dotcom app ready to go (env vars and pre-build)
|
|
await discordStep('[2/7] building dotcom app', async () => {
|
|
await createSentryRelease()
|
|
await prepareDotcomApp()
|
|
await uploadSourceMaps()
|
|
await coalesceWithPreviousAssets(`${dotcom}/.vercel/output/static/assets`)
|
|
})
|
|
|
|
await discordStep('[3/7] cloudflare deploy dry run', async () => {
|
|
await deployAssetUploadWorker({ dryRun: true })
|
|
await deployHealthWorker({ dryRun: true })
|
|
await deployTlsyncWorker({ dryRun: true })
|
|
})
|
|
|
|
// --- point of no return! do the deploy for real --- //
|
|
|
|
await discordMessage(`--- **pre-flight complete, starting real deploy** ---`)
|
|
|
|
// 2. deploy the cloudflare workers:
|
|
await discordStep('[4/7] deploying asset uploader to cloudflare', async () => {
|
|
await deployAssetUploadWorker({ dryRun: false })
|
|
})
|
|
await discordStep('[5/7] deploying multiplayer worker to cloudflare', async () => {
|
|
await deployTlsyncWorker({ dryRun: false })
|
|
})
|
|
await discordStep('[6/7] deploying health worker to cloudflare', async () => {
|
|
await deployHealthWorker({ dryRun: false })
|
|
})
|
|
|
|
// 3. deploy the pre-build dotcom app:
|
|
const { deploymentUrl, inspectUrl } = await discordStep(
|
|
'[7/7] deploying dotcom app to vercel',
|
|
async () => {
|
|
return await deploySpa()
|
|
}
|
|
)
|
|
|
|
let deploymentAlias = null as null | string
|
|
|
|
if (previewId) {
|
|
const aliasDomain = `${previewId}-preview-deploy.tldraw.com`
|
|
await discordStep('[8/7] aliasing preview deployment', async () => {
|
|
await vercelCli('alias', ['set', deploymentUrl, aliasDomain])
|
|
})
|
|
|
|
deploymentAlias = `https://${aliasDomain}`
|
|
}
|
|
|
|
nicelog('Creating deployment for', deploymentUrl)
|
|
await createGithubDeployment(deploymentAlias ?? deploymentUrl, inspectUrl)
|
|
|
|
await discordMessage(`**Deploy complete!**`)
|
|
}
|
|
|
|
async function prepareDotcomApp() {
|
|
// pre-build the app:
|
|
await exec('yarn', ['build-app'], {
|
|
env: {
|
|
NEXT_PUBLIC_TLDRAW_RELEASE_INFO: `${env.RELEASE_COMMIT_HASH} ${new Date().toISOString()}`,
|
|
ASSET_UPLOAD: previewId
|
|
? `https://${previewId}-tldraw-assets.tldraw.workers.dev`
|
|
: env.ASSET_UPLOAD,
|
|
MULTIPLAYER_SERVER: previewId
|
|
? `https://${previewId}-tldraw-multiplayer.tldraw.workers.dev`
|
|
: env.MULTIPLAYER_SERVER,
|
|
NEXT_PUBLIC_CONTROL_SERVER: 'https://control.tldraw.com',
|
|
NEXT_PUBLIC_GC_API_KEY: env.GC_MAPS_API_KEY,
|
|
SENTRY_AUTH_TOKEN: env.SENTRY_AUTH_TOKEN,
|
|
SENTRY_ORG: 'tldraw',
|
|
SENTRY_PROJECT: 'lite',
|
|
SUPABASE_KEY: env.SUPABASE_LITE_ANON_KEY,
|
|
SUPABASE_URL: env.SUPABASE_LITE_URL,
|
|
TLDRAW_ENV: env.TLDRAW_ENV,
|
|
},
|
|
})
|
|
}
|
|
|
|
let didUpdateAssetUploadWorker = false
|
|
async function deployAssetUploadWorker({ dryRun }: { dryRun: boolean }) {
|
|
if (previewId && !didUpdateAssetUploadWorker) {
|
|
appendFileSync(
|
|
join(assetUpload, 'wrangler.toml'),
|
|
`
|
|
[env.preview]
|
|
name = "${previewId}-tldraw-assets"`
|
|
)
|
|
didUpdateAssetUploadWorker = true
|
|
}
|
|
await exec('yarn', ['wrangler', 'deploy', dryRun ? '--dry-run' : null, '--env', env.TLDRAW_ENV], {
|
|
pwd: assetUpload,
|
|
env: {
|
|
NODE_ENV: 'production',
|
|
// wrangler needs CI=1 set to prevent it from trying to do interactive prompts
|
|
CI: '1',
|
|
},
|
|
})
|
|
}
|
|
|
|
let didUpdateTlsyncWorker = false
|
|
async function deployTlsyncWorker({ dryRun }: { dryRun: boolean }) {
|
|
if (previewId && !didUpdateTlsyncWorker) {
|
|
appendFileSync(
|
|
join(worker, 'wrangler.toml'),
|
|
`
|
|
[env.preview]
|
|
name = "${previewId}-tldraw-multiplayer"`
|
|
)
|
|
didUpdateTlsyncWorker = true
|
|
}
|
|
await exec(
|
|
'yarn',
|
|
[
|
|
'wrangler',
|
|
'deploy',
|
|
dryRun ? '--dry-run' : null,
|
|
'--env',
|
|
env.TLDRAW_ENV,
|
|
'--var',
|
|
`SUPABASE_URL:${env.SUPABASE_LITE_URL}`,
|
|
'--var',
|
|
`SUPABASE_KEY:${env.SUPABASE_LITE_ANON_KEY}`,
|
|
'--var',
|
|
`SENTRY_DSN:${env.WORKER_SENTRY_DSN}`,
|
|
'--var',
|
|
`TLDRAW_ENV:${env.TLDRAW_ENV}`,
|
|
'--var',
|
|
`APP_ORIGIN:${env.APP_ORIGIN}`,
|
|
],
|
|
{
|
|
pwd: worker,
|
|
env: {
|
|
NODE_ENV: 'production',
|
|
// wrangler needs CI=1 set to prevent it from trying to do interactive prompts
|
|
CI: '1',
|
|
},
|
|
}
|
|
)
|
|
}
|
|
|
|
let didUpdateHealthWorker = false
|
|
async function deployHealthWorker({ dryRun }: { dryRun: boolean }) {
|
|
if (previewId && !didUpdateHealthWorker) {
|
|
appendFileSync(
|
|
join(healthWorker, 'wrangler.toml'),
|
|
`
|
|
[env.preview]
|
|
name = "${previewId}-tldraw-health"`
|
|
)
|
|
didUpdateHealthWorker = true
|
|
}
|
|
await exec(
|
|
'yarn',
|
|
[
|
|
'wrangler',
|
|
'deploy',
|
|
dryRun ? '--dry-run' : null,
|
|
'--env',
|
|
env.TLDRAW_ENV,
|
|
'--var',
|
|
`DISCORD_HEALTH_WEBHOOK_URL:${env.DISCORD_HEALTH_WEBHOOK_URL}`,
|
|
'--var',
|
|
`HEALTH_WORKER_UPDOWN_WEBHOOK_PATH:${env.HEALTH_WORKER_UPDOWN_WEBHOOK_PATH}`,
|
|
],
|
|
{
|
|
pwd: healthWorker,
|
|
env: {
|
|
NODE_ENV: 'production',
|
|
// wrangler needs CI=1 set to prevent it from trying to do interactive prompts
|
|
CI: '1',
|
|
},
|
|
}
|
|
)
|
|
}
|
|
|
|
type ExecOpts = NonNullable<Parameters<typeof exec>[2]>
|
|
async function vercelCli(command: string, args: string[], opts?: ExecOpts) {
|
|
return exec(
|
|
'yarn',
|
|
[
|
|
'run',
|
|
'-T',
|
|
'vercel',
|
|
command,
|
|
'--token',
|
|
env.VERCEL_TOKEN,
|
|
'--scope',
|
|
env.VERCEL_ORG_ID,
|
|
'--yes',
|
|
...args,
|
|
],
|
|
{
|
|
...opts,
|
|
env: {
|
|
// specify org id via args instead of via env vars because otherwise it gets upset
|
|
// that there's no project id either
|
|
VERCEL_ORG_ID: env.VERCEL_ORG_ID,
|
|
VERCEL_PROJECT_ID: env.VERCEL_PROJECT_ID,
|
|
...opts?.env,
|
|
},
|
|
}
|
|
)
|
|
}
|
|
|
|
function sanitizeVariables(errorOutput: string): string {
|
|
const regex = /(--var\s+(\w+):[^ \n]+)/g
|
|
|
|
const sanitizedOutput = errorOutput.replace(regex, (_, match) => {
|
|
const [variable] = match.split(':')
|
|
return `${variable}:*`
|
|
})
|
|
|
|
return sanitizedOutput
|
|
}
|
|
|
|
async function discord(method: string, url: string, body: unknown): Promise<any> {
|
|
const response = await fetch(`${env.DISCORD_DEPLOY_WEBHOOK_URL}${url}`, {
|
|
method,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
})
|
|
if (!response.ok) {
|
|
throw new Error(`Discord webhook request failed: ${response.status} ${response.statusText}`)
|
|
}
|
|
return response.json()
|
|
}
|
|
|
|
const AT_TEAM_MENTION = '<@&959380625100513310>'
|
|
async function discordMessage(content: string, { always = false }: { always?: boolean } = {}) {
|
|
const shouldNotify = env.TLDRAW_ENV === 'production' || always
|
|
if (!shouldNotify) {
|
|
return {
|
|
edit: () => {
|
|
// noop
|
|
},
|
|
}
|
|
}
|
|
|
|
const message = await discord('POST', '?wait=true', { content: sanitizeVariables(content) })
|
|
|
|
return {
|
|
edit: async (newContent: string) => {
|
|
await discord('PATCH', `/messages/${message.id}`, { content: sanitizeVariables(newContent) })
|
|
},
|
|
}
|
|
}
|
|
|
|
async function discordStep<T>(content: string, cb: () => Promise<T>): Promise<T> {
|
|
const message = await discordMessage(`${content}...`)
|
|
try {
|
|
const result = await cb()
|
|
await message.edit(`${content} ✅`)
|
|
return result
|
|
} catch (err) {
|
|
await message.edit(`${content} ❌`)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
async function deploySpa(): Promise<{ deploymentUrl: string; inspectUrl: string }> {
|
|
// both 'staging' and 'production' are deployed to vercel as 'production' deploys
|
|
// in separate 'projects'
|
|
const prod = env.TLDRAW_ENV !== 'preview'
|
|
const out = await vercelCli('deploy', ['--prebuilt', ...(prod ? ['--prod'] : [])], {
|
|
pwd: dotcom,
|
|
})
|
|
|
|
const previewURL = out.match(/Preview: (https:\/\/\S*)/)?.[1]
|
|
const inspectUrl = out.match(/Inspect: (https:\/\/\S*)/)?.[1]
|
|
const productionURL = out.match(/Production: (https:\/\/\S*)/)?.[1]
|
|
const deploymentUrl = previewURL ?? productionURL
|
|
|
|
if (!deploymentUrl) {
|
|
throw new Error('Could not find deployment URL in vercel output ' + out)
|
|
}
|
|
if (!inspectUrl) {
|
|
throw new Error('Could not find inspect URL in vercel output ' + out)
|
|
}
|
|
|
|
return { deploymentUrl, inspectUrl }
|
|
}
|
|
|
|
// Creates a github 'deployment', which creates a 'View Deployment' button in the PR timeline.
|
|
async function createGithubDeployment(deploymentUrl: string, inspectUrl: string) {
|
|
const client = github.getOctokit(env.GH_TOKEN)
|
|
|
|
const deployment = await client.rest.repos.createDeployment({
|
|
owner: 'tldraw',
|
|
repo: 'tldraw',
|
|
ref: sha,
|
|
payload: { web_url: deploymentUrl },
|
|
environment: env.TLDRAW_ENV,
|
|
transient_environment: true,
|
|
required_contexts: [],
|
|
auto_merge: false,
|
|
task: 'deploy',
|
|
})
|
|
|
|
await client.rest.repos.createDeploymentStatus({
|
|
owner: 'tldraw',
|
|
repo: 'tldraw',
|
|
deployment_id: (deployment.data as any).id,
|
|
state: 'success',
|
|
environment_url: deploymentUrl,
|
|
log_url: inspectUrl,
|
|
})
|
|
}
|
|
|
|
const sentryEnv = {
|
|
SENTRY_AUTH_TOKEN: env.SENTRY_AUTH_TOKEN,
|
|
SENTRY_ORG: 'tldraw',
|
|
SENTRY_PROJECT: 'lite',
|
|
}
|
|
|
|
const execSentry = (command: string, args: string[]) =>
|
|
exec(`yarn`, ['run', '-T', 'sentry-cli', command, ...args], { env: sentryEnv })
|
|
|
|
async function createSentryRelease() {
|
|
await execSentry('releases', ['new', sentryReleaseName])
|
|
if (!existsSync(`${dotcom}/sentry-release-name.ts`)) {
|
|
throw new Error('sentry-release-name.ts does not exist')
|
|
}
|
|
writeFileSync(
|
|
`${dotcom}/sentry-release-name.ts`,
|
|
`// This file is replaced during deployments to point to a meaningful release name in Sentry.` +
|
|
`// DO NOT MESS WITH THIS LINE OR THE ONE BELOW IT. I WILL FIND YOU\n` +
|
|
`export const sentryReleaseName = '${sentryReleaseName}'`
|
|
)
|
|
}
|
|
|
|
async function uploadSourceMaps() {
|
|
const sourceMapDir = `${dotcom}/dist/assets`
|
|
|
|
await execSentry('sourcemaps', ['upload', '--release', sentryReleaseName, sourceMapDir])
|
|
execSync('rm -rf ./.next/static/chunks/**/*.map')
|
|
}
|
|
|
|
const R2_URL = `https://c34edc4e76350954b63adebde86d5eb1.r2.cloudflarestorage.com`
|
|
const R2_BUCKET = `dotcom-deploy-assets-cache`
|
|
|
|
const R2 = new S3Client({
|
|
region: 'auto',
|
|
endpoint: R2_URL,
|
|
credentials: {
|
|
accessKeyId: env.R2_ACCESS_KEY_ID,
|
|
secretAccessKey: env.R2_ACCESS_KEY_SECRET,
|
|
},
|
|
})
|
|
|
|
/**
|
|
* When we run a vite prod build it creates a folder in the output dir called assets in which
|
|
* every file includes a hash of its contents in the filename. These files include files that
|
|
* are 'imported' by the js bundle, e.g. svg and css files, along with the js bundle itself
|
|
* (split into chunks).
|
|
*
|
|
* By default, when we deploy a new version of the app it will replace the previous versions
|
|
* of the files with new versions. This is problematic when we make a new deploy because if
|
|
* existing users have tldraw open in tabs while we make the new deploy, they might still try
|
|
* to fetch .js chunks or images which are no longer present on the server and may not have
|
|
* been cached by vercel's CDN in their location (or at all).
|
|
*
|
|
* To avoid this, we keep track of the assets from previous deploys in R2 and include them in the
|
|
* new deploy. This way, if a user has an old version of the app open in a tab, they will still
|
|
* be able to fetch the old assets from the previous deploy.
|
|
*/
|
|
async function coalesceWithPreviousAssets(assetsDir: string) {
|
|
nicelog('Saving assets to R2 bucket')
|
|
const objectKey = `${previewId ?? env.TLDRAW_ENV}/${new Date().toISOString()}+${sha}.tar.gz`
|
|
const pack = tar.c({ gzip: true, cwd: assetsDir }, readdirSync(assetsDir))
|
|
// Need to pipe through a PassThrough here because the tar stream is not a first class node stream
|
|
// and AWS's sdk expects a node stream (it checks `Body instanceof streams.Readable`)
|
|
const Body = new PassThrough()
|
|
pack.pipe(Body)
|
|
await new Upload({
|
|
client: R2,
|
|
params: {
|
|
Bucket: R2_BUCKET,
|
|
Key: objectKey,
|
|
Body,
|
|
},
|
|
}).done()
|
|
|
|
nicelog('Extracting previous assets from R2 bucket')
|
|
const { Contents } = await R2.send(
|
|
new ListObjectsV2Command({
|
|
Bucket: R2_BUCKET,
|
|
Prefix: `${previewId ?? env.TLDRAW_ENV}/`,
|
|
})
|
|
)
|
|
const [mostRecent, ...others] =
|
|
// filter out the one we just uploaded
|
|
Contents?.filter((obj) => obj.Key !== objectKey).sort(
|
|
(a, b) => (b.LastModified?.getTime() ?? 0) - (a.LastModified?.getTime() ?? 0)
|
|
) ?? []
|
|
|
|
if (!mostRecent) {
|
|
nicelog('No previous assets found')
|
|
return
|
|
}
|
|
|
|
// Always include the assets from the directly previous build, but also if there
|
|
// have been more deploys in the last two weeks, include those too.
|
|
const twoWeeks = 1000 * 60 * 60 * 24 * 14
|
|
const recentOthers = others.filter(
|
|
(o) => (o.LastModified?.getTime() ?? 0) > Date.now() - twoWeeks
|
|
)
|
|
const objectsToFetch = [mostRecent, ...recentOthers]
|
|
|
|
nicelog(
|
|
`Fetching ${objectsToFetch.length} previous assets from R2 bucket:`,
|
|
objectsToFetch.map((k) => k.Key)
|
|
)
|
|
for (const obj of objectsToFetch) {
|
|
const { Body } = await R2.send(
|
|
new GetObjectCommand({
|
|
Bucket: R2_BUCKET,
|
|
Key: obj.Key,
|
|
})
|
|
)
|
|
if (!Body) {
|
|
throw new Error(`Could not fetch object ${obj.Key}`)
|
|
}
|
|
// pipe into untar
|
|
// `keep-existing` is important here because we don't want to overwrite the new assets
|
|
// if they have the same name as the old assets becuase they will have different sentry debugIds
|
|
// and it will mess up the inline source viewer on sentry errors.
|
|
const out = tar.x({ cwd: assetsDir, 'keep-existing': true })
|
|
for await (const chunk of Body?.transformToWebStream() as any as AsyncIterable<Uint8Array>) {
|
|
out.write(chunk)
|
|
}
|
|
out.end()
|
|
}
|
|
}
|
|
|
|
main().catch(async (err) => {
|
|
// don't notify discord on preview builds
|
|
if (env.TLDRAW_ENV !== 'preview') {
|
|
await discordMessage(`${AT_TEAM_MENTION} Deploy failed: ${err.stack}`, { always: true })
|
|
}
|
|
console.error(err)
|
|
process.exit(1)
|
|
})
|