mirror of
https://github.com/vercel/next.js.git
synced 2026-09-20 02:25:18 +08:00
7dcf6c0a68
Replaces the static `PREVIEW_BUILDS_BLOB_READ_WRITE_TOKEN` secret with a short-lived access token to upload to Vercel Blob in exchange for a GitHub OIDC (endpoint side: https://github.com/vercel/vercel-packages/pull/98): - the job mints GitHub Actions OIDC tokens for the `https://vercel-packages.vercel.app` audience itself (the workflow gains `id-token: write`) and re-mints shortly before expiry since a token lives about five minutes while a package batch can take longer - `upload-preview-tarballs.js` exchanges the token at vercel-packages for scoped client-upload tokens via POST and uploads the tarball bytes directly to Blob storage with `@vercel/blob/client`'s `put`, so the blob read-write token never leaves vercel-packages - the `preview-builds` environment is dropped from the job: it only existed to selectively expose the static token and restrict uploads to canary, and the canary binding is now enforced by the `job_workflow_ref` match at vercel-packages. --------- Co-authored-by: Claude Code (kimi-k3[1m]) <noreply@anthropic.com>
172 lines
5.2 KiB
JavaScript
172 lines
5.2 KiB
JavaScript
// @ts-check
|
|
const { put } = require('@vercel/blob/client')
|
|
const fs = require('node:fs/promises')
|
|
const path = require('node:path')
|
|
|
|
const PREVIEW_BUILDS_AUDIENCE = 'https://vercel-packages.vercel.app'
|
|
const DEFAULT_PREVIEW_BUILDS_BASE_URL =
|
|
'https://vercel-packages.vercel.app/next'
|
|
|
|
/**
|
|
* Mints a GitHub Actions OIDC token for the given audience.
|
|
*
|
|
* @param {string} audience
|
|
* @returns {Promise<string>}
|
|
*/
|
|
async function mintGitHubActionsOidcToken(audience) {
|
|
const requestUrl = process.env.ACTIONS_ID_TOKEN_REQUEST_URL
|
|
const requestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN
|
|
if (!requestUrl || !requestToken) {
|
|
throw new Error(
|
|
'ACTIONS_ID_TOKEN_REQUEST_URL is not set. ' +
|
|
'The job needs the `id-token: write` permission.'
|
|
)
|
|
}
|
|
|
|
const url = new URL(requestUrl)
|
|
url.searchParams.set('audience', audience)
|
|
const response = await fetch(url, {
|
|
headers: { authorization: `Bearer ${requestToken}` },
|
|
})
|
|
if (!response.ok) {
|
|
throw new Error(
|
|
`Failed to mint GitHub OIDC token: ${response.status} ${await response.text()}`
|
|
)
|
|
}
|
|
|
|
const { value } = await response.json()
|
|
return value
|
|
}
|
|
|
|
/**
|
|
* GitHub OIDC tokens expire about five minutes after issuance and uploading a
|
|
* batch of tarballs can take longer, so the token is re-minted shortly before
|
|
* it expires.
|
|
*
|
|
* @returns {() => Promise<string>}
|
|
*/
|
|
function createGitHubOidcTokenGetter() {
|
|
/** @type {string | null} */
|
|
let cachedToken = null
|
|
let cachedTokenExpiresAt = 0
|
|
|
|
return async () => {
|
|
if (cachedToken !== null && cachedTokenExpiresAt - 60_000 > Date.now()) {
|
|
return cachedToken
|
|
}
|
|
const token = await mintGitHubActionsOidcToken(PREVIEW_BUILDS_AUDIENCE)
|
|
const payload = JSON.parse(
|
|
Buffer.from(token.split('.')[1], 'base64url').toString()
|
|
)
|
|
cachedToken = token
|
|
cachedTokenExpiresAt = payload.exp * 1000
|
|
return token
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Yields one entry per package tarball under `tarballDirectory`. Scoped
|
|
* packages are laid out one level deeper (e.g. `@next/env/<name>.tgz`), so the
|
|
* walk descends into any directory whose name starts with `@`.
|
|
*
|
|
* @param {string} tarballDirectory
|
|
* @returns {AsyncGenerator<{ packageName: string, tarballPath: string }>}
|
|
*/
|
|
async function* findTarballs(tarballDirectory) {
|
|
const entries = await fs.readdir(tarballDirectory, { withFileTypes: true })
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory()) continue
|
|
const entryPath = path.join(tarballDirectory, entry.name)
|
|
if (entry.name.startsWith('@')) {
|
|
const scopeEntries = await fs.readdir(entryPath, { withFileTypes: true })
|
|
for (const scopeEntry of scopeEntries) {
|
|
if (!scopeEntry.isDirectory()) continue
|
|
const tarballPath = await findTarballInDir(
|
|
path.join(entryPath, scopeEntry.name)
|
|
)
|
|
if (tarballPath === null) continue
|
|
yield {
|
|
packageName: `${entry.name}/${scopeEntry.name}`,
|
|
tarballPath,
|
|
}
|
|
}
|
|
} else {
|
|
const tarballPath = await findTarballInDir(entryPath)
|
|
if (tarballPath === null) continue
|
|
yield { packageName: entry.name, tarballPath }
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {string} dir
|
|
* @returns {Promise<string | null>}
|
|
*/
|
|
async function findTarballInDir(dir) {
|
|
const files = await fs.readdir(dir)
|
|
const tgzFile = files.find((f) => f.endsWith('.tgz'))
|
|
return tgzFile ? path.join(dir, tgzFile) : null
|
|
}
|
|
|
|
async function main() {
|
|
const [githubHeadSha, tarballDirectory] = process.argv.slice(2)
|
|
if (!githubHeadSha || !tarballDirectory) {
|
|
throw new Error(
|
|
'Usage: node scripts/upload-preview-tarballs.js <commitSha> <tarballDirectory>'
|
|
)
|
|
}
|
|
|
|
const blobAccess = process.env.BLOB_ACCESS
|
|
if (blobAccess !== 'private' && blobAccess !== 'public') {
|
|
throw new Error(
|
|
`BLOB_ACCESS environment variable can only be "private" or "public" but got "${blobAccess}".`
|
|
)
|
|
}
|
|
const baseUrl =
|
|
process.env.PREVIEW_BUILDS_BASE_URL || DEFAULT_PREVIEW_BUILDS_BASE_URL
|
|
const getOidcToken = createGitHubOidcTokenGetter()
|
|
|
|
for await (const { packageName, tarballPath } of findTarballs(
|
|
tarballDirectory
|
|
)) {
|
|
// vercel-packages authorizes the OIDC token and answers with a client
|
|
// token scoped to this exact blob path. The bytes then flow directly to
|
|
// the store, so the tarball size is not limited by a function's request
|
|
// body limit.
|
|
const response = await fetch(
|
|
`${baseUrl}/commits/${githubHeadSha}/${packageName}`,
|
|
{
|
|
method: 'POST',
|
|
headers: { authorization: `Bearer ${await getOidcToken()}` },
|
|
}
|
|
)
|
|
if (!response.ok) {
|
|
throw new Error(
|
|
`Failed to authorize the upload of ${packageName}: ${response.status}. ` +
|
|
`Response headers: ${JSON.stringify(Object.fromEntries(response.headers))}`
|
|
)
|
|
}
|
|
const { clientToken } = await response.json()
|
|
|
|
const fileBuffer = await fs.readFile(tarballPath)
|
|
const { url } = await put(
|
|
`next/commits/${githubHeadSha}/${packageName}.tgz`,
|
|
fileBuffer,
|
|
{
|
|
access: blobAccess,
|
|
token: clientToken,
|
|
contentType: 'application/gzip',
|
|
multipart: true,
|
|
}
|
|
)
|
|
console.info(`Uploaded ${packageName} -> ${url}`)
|
|
}
|
|
|
|
console.info('All tarballs uploaded')
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error(err)
|
|
process.exit(1)
|
|
})
|