Files
supabase__supabase/scripts/waitForVercelPreview.js
Katerina Skroumpelou 028e05205b docs: warn that default signOut scope revokes all sessions (#50119)
Warn that default signOut scope revokes all sessions. Motivation:
https://github.com/supabase/ssr/issues/68

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Documentation**
* Clarified that signing out without a specified scope ends all sessions
by default.
* Added guidance for using a local sign-out scope to preserve sessions
on other devices and browsers.
* Documented the invalid refresh token errors that may occur when other
sessions are revoked.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-10 12:37:06 +00:00

210 lines
7.5 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Vercel's GitHub App has stopped writing GitHub Deployment objects since
// 2026-02-17 (broken app auth), so polling the Deployments API (as
// vercel/wait-for-deployment-action does) times out even though the preview
// builds fine. Poll the project's Vercel commit status instead, then resolve
// the actual preview URL via Vercel's own deployments API.
//
// A build that Vercel's Ignored Build Step skips still gets a successful
// commit status, but the deployment behind it is CANCELED and its URL serves
// a placeholder page with a 200 status. Such a preview is not testable, so
// the URL is resolved from the newest earlier commit of the pull request
// whose deployment is READY. Vercel skips a build only when the commit does
// not affect the project, so that earlier preview serves the same content.
//
// Set VERCEL_STATUS_CONTEXT to the project's commit status name, e.g.
// "Vercel docs" or "Vercel zone-www-dot-com". PR_NUMBER enables the
// fallback to earlier commits of the pull request.
const { appendFileSync } = require('fs')
const TIMEOUT_MS = 900_000
const POLL_INTERVAL_MS = 15_000
const GITHUB_PAGE_SIZE = 100
function deploymentIdFromTargetUrl(targetUrl) {
let rawId
try {
rawId = new URL(targetUrl).pathname.split('/').filter(Boolean).pop()
} catch {
rawId = undefined
}
if (!rawId) {
throw new Error(`Could not parse a deployment ID from target_url: ${targetUrl}`)
}
return rawId.startsWith('dpl_') ? rawId : `dpl_${rawId}`
}
function shortSha(sha) {
return sha.slice(0, 10)
}
function createClients({ fetchImpl = fetch, repository, githubToken, vercelToken, teamId }) {
const githubHeaders = {
Authorization: `Bearer ${githubToken}`,
Accept: 'application/vnd.github+json',
}
const vercelHeaders = { Authorization: `Bearer ${vercelToken}` }
async function getJson(url, headers, what) {
const response = await fetchImpl(url, { headers })
if (!response.ok) {
throw new Error(`Failed to ${what}: ${response.status} ${response.statusText}`)
}
return response.json()
}
return {
// Newest status for the context, or undefined when Vercel has not posted one.
async latestStatus(sha, statusContext) {
const statuses = await getJson(
`https://api.github.com/repos/${repository}/commits/${sha}/statuses`,
githubHeaders,
`fetch commit statuses for ${shortSha(sha)}`
)
return statuses
.filter((status) => status.context === statusContext)
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0]
},
// Commit SHAs of the pull request, newest first.
async pullRequestShas(prNumber) {
const shas = []
for (let page = 1; ; page += 1) {
const commits = await getJson(
`https://api.github.com/repos/${repository}/pulls/${prNumber}/commits?per_page=${GITHUB_PAGE_SIZE}&page=${page}`,
githubHeaders,
`fetch commits for pull request #${prNumber}`
)
shas.push(...commits.map((commit) => commit.sha))
if (commits.length < GITHUB_PAGE_SIZE) break
}
return shas.reverse()
},
async deployment(targetUrl) {
const deploymentId = deploymentIdFromTargetUrl(targetUrl)
const query = teamId ? `?teamId=${teamId}` : ''
const deployment = await getJson(
`https://api.vercel.com/v13/deployments/${deploymentId}${query}`,
vercelHeaders,
`resolve Vercel deployment ${deploymentId}`
)
return { url: `https://${deployment.url}`, state: deployment.readyState ?? deployment.status }
},
}
}
async function resolveFromEarlierCommits(clients, { headSha, prNumber, statusContext, log }) {
if (!prNumber) {
throw new Error(
`The "${statusContext}" build for ${shortSha(headSha)} was skipped, and PR_NUMBER is required to fall back to an earlier commit's preview`
)
}
const shas = await clients.pullRequestShas(prNumber)
for (const sha of shas) {
if (sha === headSha) continue
const status = await clients.latestStatus(sha, statusContext)
if (status?.state !== 'success' || !status.target_url) continue
const deployment = await clients.deployment(status.target_url)
if (deployment.state === 'READY') {
log(`Using the "${statusContext}" preview built for ${shortSha(sha)}: ${deployment.url}`)
return deployment.url
}
}
throw new Error(
`No READY "${statusContext}" preview among the ${shas.length} commits of pull request #${prNumber}`
)
}
async function resolvePreviewUrl(clients, options) {
const { headSha, statusContext, timeoutMs, pollIntervalMs, sleep, now, log } = options
const start = now()
for (;;) {
const latest = await clients.latestStatus(headSha, statusContext)
if (latest?.state === 'success') {
if (!latest.target_url) {
throw new Error(
`"${statusContext}" commit status succeeded but had no target_url to resolve a deployment from`
)
}
const deployment = await clients.deployment(latest.target_url)
if (deployment.state === 'READY') {
return deployment.url
}
if (deployment.state === 'CANCELED') {
log(
`"${statusContext}" for ${shortSha(headSha)} reports "${latest.description}" and its deployment is CANCELED; looking for the newest READY preview among earlier commits`
)
return resolveFromEarlierCommits(clients, options)
}
throw new Error(
`"${statusContext}" deployment for ${shortSha(headSha)} is ${deployment.state}, not READY`
)
}
if (latest?.state === 'failure' || latest?.state === 'error') {
throw new Error(`"${statusContext}" deployment failed (commit status: ${latest.state})`)
}
if (now() - start > timeoutMs) {
throw new Error(
`Timed out after ${Math.round(timeoutMs / 1000)}s waiting for the "${statusContext}" preview deployment`
)
}
await sleep(pollIntervalMs)
}
}
function writeOutput(name, value) {
const outputFile = process.env.GITHUB_OUTPUT
if (!outputFile) {
throw new Error('GITHUB_OUTPUT environment variable is required')
}
appendFileSync(outputFile, `${name}=${value}\n`)
}
async function main() {
const repository = process.env.GITHUB_REPOSITORY
const sha = process.env.HEAD_SHA
const githubToken = process.env.GITHUB_TOKEN
const vercelToken = process.env.VERCEL_TOKEN
const teamId = process.env.VERCEL_TEAM_ID
const statusContext = process.env.VERCEL_STATUS_CONTEXT
const prNumber = process.env.PR_NUMBER
if (!repository) throw new Error('GITHUB_REPOSITORY environment variable is required')
if (!sha) throw new Error('HEAD_SHA environment variable is required')
if (!githubToken) throw new Error('GITHUB_TOKEN environment variable is required')
if (!vercelToken) throw new Error('VERCEL_TOKEN environment variable is required')
if (!statusContext) throw new Error('VERCEL_STATUS_CONTEXT environment variable is required')
const clients = createClients({ repository, githubToken, vercelToken, teamId })
const url = await resolvePreviewUrl(clients, {
headSha: sha,
prNumber,
statusContext,
timeoutMs: TIMEOUT_MS,
pollIntervalMs: POLL_INTERVAL_MS,
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
now: Date.now,
log: console.log,
})
writeOutput('deployment-url', url)
}
module.exports = { createClients, deploymentIdFromTargetUrl, resolvePreviewUrl }
if (require.main === module) {
main().catch((error) => {
console.error('Fatal error:', error)
process.exit(1)
})
}