diff --git a/.github/workflows/docs-e2e.yml b/.github/workflows/docs-e2e.yml index c608a8418af..289bb4aebeb 100644 --- a/.github/workflows/docs-e2e.yml +++ b/.github/workflows/docs-e2e.yml @@ -129,6 +129,10 @@ jobs: # Vercel skips the docs preview when a PR only changes the harness # (e2e/docs, workflow), so wait for a preview only when apps/docs changed. + # When apps/docs changed earlier in the PR but not in the head commit, + # Vercel skips the head build as not affected and its URL serves a + # placeholder page; the script then uses the newest READY preview from + # an earlier commit of the PR, which serves the same docs content. # # Vercel's GitHub App stopped writing GitHub Deployment objects on # 2026-02-17 (broken app auth), so vercel/wait-for-deployment-action @@ -146,6 +150,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_NUMBER: ${{ github.event.pull_request.number }} VERCEL_STATUS_CONTEXT: 'Vercel – docs' VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} VERCEL_TEAM_ID: ${{ secrets.VERCEL_TEAM_ID }} diff --git a/.github/workflows/www-e2e.yml b/.github/workflows/www-e2e.yml index 7859ca0033c..2d52534798d 100644 --- a/.github/workflows/www-e2e.yml +++ b/.github/workflows/www-e2e.yml @@ -120,7 +120,10 @@ jobs: cache: 'pnpm' # Vercel skips the preview when only the harness changed, so wait for one - # only when apps/www changed. See scripts/waitForVercelPreview.js. + # only when apps/www changed. A head commit that leaves apps/www untouched + # gets a skipped build whose URL serves a placeholder page, so the script + # falls back to the newest READY preview from an earlier commit of the PR. + # See scripts/waitForVercelPreview.js. - name: Wait for Vercel www preview if: steps.scope.outputs.skip == 'false' && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && steps.changes.outputs.www_app == 'true' id: deployment @@ -129,6 +132,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_NUMBER: ${{ github.event.pull_request.number }} VERCEL_STATUS_CONTEXT: 'Vercel – zone-www-dot-com' VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} VERCEL_TEAM_ID: ${{ secrets.VERCEL_TEAM_ID }} diff --git a/apps/docs/content/guides/auth/server-side/advanced-guide.mdx b/apps/docs/content/guides/auth/server-side/advanced-guide.mdx index 8366b9a7c99..5659375a70f 100644 --- a/apps/docs/content/guides/auth/server-side/advanced-guide.mdx +++ b/apps/docs/content/guides/auth/server-side/advanced-guide.mdx @@ -46,6 +46,8 @@ It is likely that the refresh token sent from the browser to your server is stal When you receive this error on the server-side, try to defer rendering to the browser where the client library can access an up-to-date refresh token and present the user with a better experience. +A common cause is calling `supabase.auth.signOut()` without a `scope`. It defaults to `scope: 'global'`, which revokes the refresh token for every session that user has. That includes other tabs, devices, and any SSR context still holding the old token. See [Sign out and scopes](/docs/guides/auth/signout#sign-out-and-scopes). Pass `{ scope: 'local' }` if you only want to end the current session. + ### Should I set a shorter `Max-Age` parameter on the cookies? The `Max-Age` or `Expires` cookie parameters only control whether the browser sends the value to the server. Since a refresh token represents the long-lived authentication session of the user on that browser, setting a short `Max-Age` or `Expires` parameter on the cookies only results in a degraded user experience. diff --git a/apps/docs/content/guides/auth/signout.mdx b/apps/docs/content/guides/auth/signout.mdx index dd932904e60..7bdc0a32fec 100644 --- a/apps/docs/content/guides/auth/signout.mdx +++ b/apps/docs/content/guides/auth/signout.mdx @@ -89,6 +89,16 @@ Supabase Auth allows you to specify three different scopes for when a user invok You can invoke these by providing the `scope` option: + + +JavaScript, Swift, Python, and C# default to the `global` scope. Dart and Kotlin default to `local`. + +With the `global` scope, calling `signOut()` on one device revokes the refresh tokens for every session that user has, including other devices and browsers. Those other sessions fail with `AuthApiError: Invalid Refresh Token: Refresh Token Not Found` the next time they try to refresh, even though no one signed out on that device. + +If your app expects independent sessions per device, pass the `local` scope explicitly, as shown in the examples below. + + + { await expect(article, 'Page article should be present').toBeVisible() const links = await collectDocsOwnedLinks(page, baseURL!, articleSelector) - const userAgent = await browserLikeUserAgent(page) for (const url of links) { - try { - const linkResponse = await page.request.get(url, { headers: { 'user-agent': userAgent } }) - expect - .soft(linkResponse.ok(), `${url} should resolve (status ${linkResponse.status()})`) - .toBeTruthy() - } catch (error) { - expect - .soft( - null, - `${url} should be reachable (${error instanceof Error ? error.message : error})` - ) - .toBeTruthy() - } + const result = await checkLinkFromBrowser(page, url) + expect + .soft( + result.ok, + result.error + ? `${url} should be reachable (${result.error})` + : `${url} should resolve (status ${result.status})` + ) + .toBeTruthy() } }) } diff --git a/e2e/docs/utils/docs-links.ts b/e2e/docs/utils/docs-links.ts index 58942279c39..16e88164942 100644 --- a/e2e/docs/utils/docs-links.ts +++ b/e2e/docs/utils/docs-links.ts @@ -55,8 +55,23 @@ export async function collectDocsOwnedLinks( return [...links].sort() } -// Vercel bot protection blocks the HeadlessChrome UA on some routes; strip it. -export async function browserLikeUserAgent(page: Page): Promise { - const userAgent = await page.evaluate(() => navigator.userAgent) - return userAgent.replace('HeadlessChrome', 'Chrome') +export type LinkCheckResult = { + ok: boolean + status: number + error?: string +} + +// The docs middleware rewrites /reference/* requests whose user agent looks like a bot +// (isbot, which matches HeadlessChrome) to /api/crawlers. Fetching from inside the page +// keeps the browser's own user agent and network stack, so a link resolves the same way +// page.goto resolves it in this suite. +export async function checkLinkFromBrowser(page: Page, url: string): Promise { + return page.evaluate(async (linkUrl) => { + try { + const response = await fetch(linkUrl) + return { ok: response.ok, status: response.status } + } catch (error) { + return { ok: false, status: 0, error: error instanceof Error ? error.message : String(error) } + } + }, url) } diff --git a/scripts/waitForVercelPreview.js b/scripts/waitForVercelPreview.js index 37dc2d34616..ab38b109779 100644 --- a/scripts/waitForVercelPreview.js +++ b/scripts/waitForVercelPreview.js @@ -4,56 +4,162 @@ // 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". +// "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 -async function fetchLatestStatus(repository, sha, githubToken, statusContext) { - const url = `https://api.github.com/repos/${repository}/commits/${sha}/statuses` - const response = await fetch(url, { - headers: { - Authorization: `Bearer ${githubToken}`, - Accept: 'application/vnd.github+json', - }, - }) - - if (!response.ok) { - throw new Error(`Failed to fetch commit statuses: ${response.status} ${response.statusText}`) +function deploymentIdFromTargetUrl(targetUrl) { + let rawId + try { + rawId = new URL(targetUrl).pathname.split('/').filter(Boolean).pop() + } catch { + rawId = undefined } - - const statuses = await response.json() - - return statuses - .filter((status) => status.context === statusContext) - .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0] -} - -async function resolveDeploymentUrl(targetUrl, vercelToken, teamId) { - const rawId = targetUrl.split('/').filter(Boolean).pop() if (!rawId) { throw new Error(`Could not parse a deployment ID from target_url: ${targetUrl}`) } - const deploymentId = rawId.startsWith('dpl_') ? rawId : `dpl_${rawId}` + return rawId.startsWith('dpl_') ? rawId : `dpl_${rawId}` +} - const url = teamId - ? `https://api.vercel.com/v13/deployments/${deploymentId}?teamId=${teamId}` - : `https://api.vercel.com/v13/deployments/${deploymentId}` +function shortSha(sha) { + return sha.slice(0, 10) +} - const response = await fetch(url, { - headers: { Authorization: `Bearer ${vercelToken}` }, - }) +function createClients({ fetchImpl = fetch, repository, githubToken, vercelToken, teamId }) { + const githubHeaders = { + Authorization: `Bearer ${githubToken}`, + Accept: 'application/vnd.github+json', + } + const vercelHeaders = { Authorization: `Bearer ${vercelToken}` } - if (!response.ok) { + 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( - `Failed to resolve Vercel deployment ${deploymentId}: ${response.status} ${response.statusText}` + `The "${statusContext}" build for ${shortSha(headSha)} was skipped, and PR_NUMBER is required to fall back to an earlier commit's preview` ) } - const deployment = await response.json() - return `https://${deployment.url}` + 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) { @@ -71,6 +177,7 @@ async function main() { 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') @@ -78,35 +185,25 @@ async function main() { if (!vercelToken) throw new Error('VERCEL_TOKEN environment variable is required') if (!statusContext) throw new Error('VERCEL_STATUS_CONTEXT environment variable is required') - const start = Date.now() - - for (;;) { - const latest = await fetchLatestStatus(repository, sha, githubToken, 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 deploymentUrl = await resolveDeploymentUrl(latest.target_url, vercelToken, teamId) - writeOutput('deployment-url', deploymentUrl) - return - } - - if (latest?.state === 'failure' || latest?.state === 'error') { - throw new Error(`"${statusContext}" deployment failed (commit status: ${latest.state})`) - } - - if (Date.now() - start > TIMEOUT_MS) { - throw new Error(`Timed out after 900s waiting for the "${statusContext}" preview deployment`) - } - - await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)) - } + 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) } -main().catch((error) => { - console.error('Fatal error:', error) - process.exit(1) -}) +module.exports = { createClients, deploymentIdFromTargetUrl, resolvePreviewUrl } + +if (require.main === module) { + main().catch((error) => { + console.error('Fatal error:', error) + process.exit(1) + }) +} diff --git a/scripts/waitForVercelPreview.test.js b/scripts/waitForVercelPreview.test.js new file mode 100644 index 00000000000..0c74acdd864 --- /dev/null +++ b/scripts/waitForVercelPreview.test.js @@ -0,0 +1,196 @@ +const assert = require('node:assert/strict') +const { test } = require('node:test') + +const { deploymentIdFromTargetUrl, resolvePreviewUrl } = require('./waitForVercelPreview.js') + +const CONTEXT = 'Vercel – docs' +const HEAD = 'head000000000000000000000000000000000000' +const OLDER = 'older00000000000000000000000000000000000' +const OLDEST = 'oldest0000000000000000000000000000000000' + +function success(id, description = 'Deployment has completed') { + return { state: 'success', description, target_url: `https://vercel.com/supabase/docs/${id}` } +} + +function fakeClients({ statuses = {}, deployments = {}, prShas = [] }) { + const calls = { latestStatus: [], deployment: [], pullRequestShas: 0 } + return { + calls, + async latestStatus(sha) { + calls.latestStatus.push(sha) + const list = statuses[sha] + return Array.isArray(list) ? list.shift() : list + }, + async pullRequestShas() { + calls.pullRequestShas += 1 + return prShas + }, + async deployment(targetUrl) { + calls.deployment.push(targetUrl) + return deployments[deploymentIdFromTargetUrl(targetUrl)] + }, + } +} + +function options(overrides = {}) { + const log = [] + return { + headSha: HEAD, + prNumber: 50119, + statusContext: CONTEXT, + timeoutMs: 1000, + pollIntervalMs: 10, + sleep: async () => {}, + now: () => 0, + log: (line) => log.push(line), + logLines: log, + ...overrides, + } +} + +test('deploymentIdFromTargetUrl prefixes bare inspector ids and keeps dpl_ ids', () => { + assert.equal( + deploymentIdFromTargetUrl('https://vercel.com/supabase/docs/Avoo1vcp'), + 'dpl_Avoo1vcp' + ) + assert.equal( + deploymentIdFromTargetUrl('https://vercel.com/supabase/docs/dpl_Avoo1vcp'), + 'dpl_Avoo1vcp' + ) + assert.throws(() => deploymentIdFromTargetUrl('https://vercel.com'), /deployment ID/) +}) + +test('returns the head deployment when it is READY', async () => { + const clients = fakeClients({ + statuses: { [HEAD]: success('dpl_head') }, + deployments: { dpl_head: { url: 'https://docs-head.vercel.app', state: 'READY' } }, + }) + + const url = await resolvePreviewUrl(clients, options()) + + assert.equal(url, 'https://docs-head.vercel.app') + assert.equal(clients.calls.pullRequestShas, 0) +}) + +test('polls while the head status is pending', async () => { + let clock = 0 + const sleeps = [] + const clients = fakeClients({ + statuses: { [HEAD]: [undefined, { state: 'pending' }, success('dpl_head')] }, + deployments: { dpl_head: { url: 'https://docs-head.vercel.app', state: 'READY' } }, + }) + + const url = await resolvePreviewUrl( + clients, + options({ + now: () => clock, + sleep: async (ms) => { + sleeps.push(ms) + clock += ms + }, + }) + ) + + assert.equal(url, 'https://docs-head.vercel.app') + assert.deepEqual(sleeps, [10, 10]) +}) + +test('falls back to the newest earlier commit with a READY deployment when the head build was skipped', async () => { + const clients = fakeClients({ + prShas: [HEAD, OLDER, OLDEST], + statuses: { + [HEAD]: success('dpl_head', 'Skipped - Not affected'), + [OLDER]: undefined, + [OLDEST]: success('dpl_oldest'), + }, + deployments: { + dpl_head: { url: 'https://docs-head.vercel.app', state: 'CANCELED' }, + dpl_oldest: { url: 'https://docs-oldest.vercel.app', state: 'READY' }, + }, + }) + const opts = options() + + const url = await resolvePreviewUrl(clients, opts) + + assert.equal(url, 'https://docs-oldest.vercel.app') + assert.deepEqual(clients.calls.latestStatus, [HEAD, OLDER, OLDEST]) + assert.ok(opts.logLines.some((line) => line.includes('Skipped - Not affected'))) + assert.ok(opts.logLines.some((line) => line.includes(OLDEST.slice(0, 10)))) +}) + +test('skips earlier commits whose deployment is not READY', async () => { + const clients = fakeClients({ + prShas: [HEAD, OLDER, OLDEST], + statuses: { + [HEAD]: success('dpl_head', 'Skipped - Not affected'), + [OLDER]: success('dpl_older', 'Skipped - Not affected'), + [OLDEST]: success('dpl_oldest'), + }, + deployments: { + dpl_head: { url: 'https://docs-head.vercel.app', state: 'CANCELED' }, + dpl_older: { url: 'https://docs-older.vercel.app', state: 'CANCELED' }, + dpl_oldest: { url: 'https://docs-oldest.vercel.app', state: 'READY' }, + }, + }) + + const url = await resolvePreviewUrl(clients, options()) + + assert.equal(url, 'https://docs-oldest.vercel.app') +}) + +test('throws when the head build was skipped and no earlier commit has a READY deployment', async () => { + const clients = fakeClients({ + prShas: [HEAD, OLDER], + statuses: { + [HEAD]: success('dpl_head', 'Skipped - Not affected'), + [OLDER]: { state: 'failure', target_url: 'https://vercel.com/supabase/docs/dpl_older' }, + }, + deployments: { dpl_head: { url: 'https://docs-head.vercel.app', state: 'CANCELED' } }, + }) + + await assert.rejects(resolvePreviewUrl(clients, options()), /No READY "Vercel – docs" preview/) +}) + +test('throws when the head build was skipped and no pull request number is available', async () => { + const clients = fakeClients({ + statuses: { [HEAD]: success('dpl_head', 'Skipped - Not affected') }, + deployments: { dpl_head: { url: 'https://docs-head.vercel.app', state: 'CANCELED' } }, + }) + + await assert.rejects(resolvePreviewUrl(clients, options({ prNumber: undefined })), /PR_NUMBER/) + assert.equal(clients.calls.pullRequestShas, 0) +}) + +test('throws when the head deployment is in any state other than READY or CANCELED', async () => { + const clients = fakeClients({ + statuses: { [HEAD]: success('dpl_head') }, + deployments: { dpl_head: { url: 'https://docs-head.vercel.app', state: 'ERROR' } }, + }) + + await assert.rejects(resolvePreviewUrl(clients, options()), /ERROR/) +}) + +test('throws when the head status reports a failed deployment', async () => { + const clients = fakeClients({ statuses: { [HEAD]: { state: 'failure' } } }) + + await assert.rejects(resolvePreviewUrl(clients, options()), /deployment failed/) +}) + +test('throws when the head status never resolves before the timeout', async () => { + let clock = 0 + const clients = fakeClients({ statuses: { [HEAD]: { state: 'pending' } } }) + + await assert.rejects( + resolvePreviewUrl( + clients, + options({ + timeoutMs: 25, + now: () => clock, + sleep: async (ms) => { + clock += ms + }, + }) + ), + /Timed out/ + ) +})