Files
upstream-tldraw/internal/scripts/trigger-dotcom-hotfix.ts
upstream-mirror a774ac673a
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
2026-09-23 16:12:52 +05:00

225 lines
8.5 KiB
TypeScript

import { Octokit } from 'octokit'
import { Discord } from './lib/discord'
import { exec } from './lib/exec'
import { makeEnv } from './lib/makeEnv'
import { nicelog } from './lib/nicelog'
import { getPrDetailsAndCommitSha, getPrDetailsByNumber, labelPresent } from './lib/pr-info'
import { stripSkipCiMarkers } from './lib/skip-ci'
function getEnv() {
return makeEnv(['DISCORD_DEPLOY_WEBHOOK_URL', 'GITHUB_TOKEN'])
}
async function main() {
const env = getEnv()
const octokit = new Octokit({ auth: env.GITHUB_TOKEN })
const result = await getPrDetailsAndCommitSha(octokit)
if (!result) {
nicelog('Could not retrieve PR details. Exiting...')
return
}
const { pr, commitSha } = result
if (!labelPresent(pr, 'dotcom-hotfix-please')) {
nicelog('No dotcom-hotfix-please label found. Exiting...')
return
}
const discord = new Discord({
webhookUrl: env.DISCORD_DEPLOY_WEBHOOK_URL,
totalSteps: 4,
shouldNotify: true,
secretValues: Object.values(env),
})
await discord.message(`🚀 Triggering dotcom hotfix for PR #${pr.number}...`)
const hotfixBranchName = `hotfix/dotcom-${pr.number}`
await discord.step(`Creating hotfix branch and cherry-picking changes`, async () => {
await exec('git', ['fetch', 'origin', 'hotfixes'])
await exec('git', ['fetch', 'origin', 'main'])
await exec('git', ['checkout', 'hotfixes'])
await exec('git', ['reset', '--hard', 'origin/hotfixes'])
await exec('git', ['checkout', '-b', hotfixBranchName])
await exec('git', ['cherry-pick', commitSha])
// release-notes PRs squash-merge with `[skip ci]` in the title. github honours that marker on
// the cherry-picked commit too, so the hotfix branch's required checks would never start and
// the PR below would sit in `blocked` until the timeout. strip it before pushing.
const message = (await exec('git', ['log', '-1', '--format=%B'])).trim()
const cleanedMessage = stripSkipCiMarkers(message)
if (cleanedMessage !== message) {
await exec('git', ['commit', '--amend', '-m', cleanedMessage])
}
})
await discord.step('Pushing hotfix branch to remote', async () => {
await exec('git', ['push', 'origin', hotfixBranchName])
})
await discord.step('Creating hotfix PR and waiting for checks to pass', async () => {
// the squash-merge onto `hotfixes` is what triggers the production build, and github reads
// skip-ci markers from the whole commit message. keep them out of the title (used as the
// commit subject) and the body (used as the commit body when someone merges by hand).
const originalTitle = stripSkipCiMarkers(pr.title)
const prTitle = `[HOTFIX] ${originalTitle}`
// Extract API changes section from original PR if present
const apiChangesHeader = '### API changes'
let apiChangesSection = ''
if (pr.body?.includes(apiChangesHeader)) {
const bodyAfterHeader = pr.body.split(apiChangesHeader)[1]
// Extract until next ### header or end of body
const nextHeaderIndex = bodyAfterHeader.indexOf('\n###')
apiChangesSection =
nextHeaderIndex > -1 ? bodyAfterHeader.slice(0, nextHeaderIndex) : bodyAfterHeader
apiChangesSection = `\n\n${apiChangesHeader}\n${apiChangesSection.trim()}\n`
}
const prBody = `This is an automated hotfix PR for dotcom deployment.
**Original PR:** [#${pr.number}](https://github.com/tldraw/tldraw/pull/${pr.number})
**Original Title:** ${originalTitle}
**Original Author:** @${pr.user?.login}
This PR cherry-picks the changes from the original PR to the hotfixes branch for immediate dotcom deployment.${apiChangesSection}
/cc @${pr.user?.login}`
const createdPr = await octokit.rest.pulls.create({
owner: 'tldraw',
repo: 'tldraw',
title: prTitle,
body: prBody,
head: hotfixBranchName,
base: 'hotfixes',
})
nicelog(`Created hotfix PR: ${hotfixBranchName} -> hotfixes`)
await discord.message(
`📝 Created hotfix PR: <https://github.com/tldraw/tldraw/pull/${createdPr.data.number}>`
)
nicelog(`Waiting for PR #${createdPr.data.number} to be ready for merge...`)
async function noteManualMerge(mergedByLogin: string | undefined) {
const by = mergedByLogin ? `@${mergedByLogin}` : 'someone'
nicelog(`Hotfix PR #${createdPr.data.number} was merged manually by ${by}`)
await discord.message(`✅ Hotfix PR #${createdPr.data.number} was merged manually by ${by}`)
}
// Maximum wait time: 15 minutes total (action timeout is 20 mins, we need buffer for Discord notification)
const maxWaitTimeMs = 15 * 60 * 1000
const startTime = Date.now()
// Wait for 5 minutes initially, then check every 15 seconds (our checks take at least 5 mins)
await new Promise((resolve) => setTimeout(resolve, 5 * 60 * 1000))
let checkedForMissingChecks = false
while (true) {
// Check if we've exceeded the timeout
const elapsedTime = Date.now() - startTime
if (elapsedTime >= maxWaitTimeMs) {
nicelog(`Timeout: PR #${createdPr.data.number} checks did not complete in time`)
throw new Error(
`Hotfix PR #${createdPr.data.number} checks timed out after ${Math.round(elapsedTime / 60000)} minutes. Please check the PR manually: https://github.com/tldraw/tldraw/pull/${createdPr.data.number}`
)
}
const prStatus = await getPrDetailsByNumber(octokit, createdPr.data.number)
// github stops reporting mergeability once a PR is merged or closed by hand, so without
// this we'd spin until the timeout and report a false failure for a hotfix that landed.
if (prStatus.merged) {
await noteManualMerge(prStatus.merged_by?.login)
break
}
if (prStatus.state === 'closed') {
throw new Error(`Hotfix PR #${createdPr.data.number} was closed without being merged`)
}
nicelog(`PR #${createdPr.data.number} mergeable_state: ${prStatus.mergeable_state}`)
if (prStatus.mergeable_state === 'clean') {
nicelog(`PR #${createdPr.data.number} is ready for merge`)
try {
await octokit.rest.pulls.merge({
owner: 'tldraw',
repo: 'tldraw',
pull_number: createdPr.data.number,
merge_method: 'squash',
commit_title: prTitle,
commit_message: `This is an automated hotfix for dotcom deployment.
Original PR: #${pr.number}
Original Author: @${pr.user?.login}`,
})
} catch (error) {
// the merge api rejects an already-merged PR, so re-check before reporting a failure
const latest = await getPrDetailsByNumber(octokit, createdPr.data.number)
if (!latest.merged) throw error
await noteManualMerge(latest.merged_by?.login)
break
}
nicelog(`Successfully merged hotfix PR #${createdPr.data.number}`)
break
} else if (prStatus.mergeable_state === 'unstable') {
nicelog(`PR #${createdPr.data.number} is unstable (some checks failed)`)
throw new Error(`Hotfix PR #${createdPr.data.number} is unstable`)
} else if (prStatus.mergeable_state === 'dirty') {
nicelog(`PR #${createdPr.data.number} has conflicts and cannot be merged`)
throw new Error(`Hotfix PR #${createdPr.data.number} has conflicts`)
} else {
// `blocked` with no check runs at all means nothing is going to change: the push didn't
// start any workflows (a skip-ci marker that survived, or a required check that isn't
// wired up). fail now with a pointer instead of spinning until the timeout.
if (prStatus.mergeable_state === 'blocked' && !checkedForMissingChecks) {
checkedForMissingChecks = true
const checks = await octokit.rest.checks.listForRef({
owner: 'tldraw',
repo: 'tldraw',
ref: prStatus.head.sha,
per_page: 1,
})
if (checks.data.total_count === 0) {
throw new Error(
`Hotfix PR #${createdPr.data.number} is blocked and no check runs have started on ${hotfixBranchName}. The required checks will never report, so it can't be merged automatically. Look at the commit message on that branch for a skip-ci marker: https://github.com/tldraw/tldraw/pull/${createdPr.data.number}`
)
}
}
nicelog(
`PR #${createdPr.data.number} merge status: ${prStatus.mergeable_state}, waiting...`
)
await new Promise((resolve) => setTimeout(resolve, 15 * 1000))
continue
}
}
})
await discord.step('Checks have passed, deploy will start soon', async () => {
// This step just provides user feedback after successful merge
})
}
main().catch(async (e: Error) => {
console.error(e)
const env = getEnv()
const discord = new Discord({
webhookUrl: env.DISCORD_DEPLOY_WEBHOOK_URL,
totalSteps: 3,
shouldNotify: true,
secretValues: Object.values(env),
})
await discord
.message(
`❌ **Error triggering dotcom hotfix**\n\n\`\`\`ansi\n${e.message.slice(0, 2000)}\n\`\`\``
)
.finally(() => {
process.exit(1)
})
})