From 3e8508e9ad01bebc06786f317ddd84e7f825a425 Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Mon, 11 May 2026 17:31:32 +0700 Subject: [PATCH] chore(release): deploy web release to Vercel --- .github/workflows/release.yml | 32 ++++ scripts/prepare-vercel-release-deploy.mjs | 193 ++++++++++++++++++++++ 2 files changed, 225 insertions(+) create mode 100644 scripts/prepare-vercel-release-deploy.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cc14b33f..52f2feb8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -340,6 +340,14 @@ jobs: GITHUB_REPOSITORY: ${{ github.repository }} GITHUB_REF_NAME: ${{ github.ref_name }} run: node scripts/release-body > release-body.txt + - name: Check Vercel deployment token + env: + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + run: | + if [ -z "${VERCEL_TOKEN:-}" ]; then + echo "::error::VERCEL_TOKEN is required to deploy the release HTML archive to Vercel Production." + exit 1 + fi - name: Create GitHub release uses: ncipollo/release-action@v1 with: @@ -347,3 +355,27 @@ jobs: bodyFile: "release-body.txt" allowUpdates: true token: ${{ secrets.GITHUB_TOKEN }} + - name: Deploy web release to Vercel Production + env: + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + VERCEL_ORG_ID: team_drHWHjDIQypSvbjZ1F468uLm + VERCEL_PROJECT_ID: prj_oeEtip9l1zUvJF00QdhMXpXn2CH7 + run: | + if [ -z "${VERCEL_TOKEN:-}" ]; then + echo "::error::VERCEL_TOKEN is required to deploy the release HTML archive to Vercel Production." + exit 1 + fi + + VERSION="${GITHUB_REF_NAME#v}" + RELEASE_ASSET="release-assets/5chan-${VERSION}-html.zip" + STAGED_DIR=$(node scripts/prepare-vercel-release-deploy.mjs --tag "$GITHUB_REF_NAME" --asset "$RELEASE_ASSET" --out-dir vercel-release-deploy) + corepack yarn dlx vercel@50.23.2 deploy "$STAGED_DIR" \ + --prebuilt \ + --prod \ + --yes \ + --force \ + --archive=tgz \ + --token "$VERCEL_TOKEN" \ + --meta source=github-release \ + --meta releaseTag="$GITHUB_REF_NAME" \ + --meta releaseAsset="5chan-${VERSION}-html.zip" diff --git a/scripts/prepare-vercel-release-deploy.mjs b/scripts/prepare-vercel-release-deploy.mjs new file mode 100644 index 00000000..1b34aacf --- /dev/null +++ b/scripts/prepare-vercel-release-deploy.mjs @@ -0,0 +1,193 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptDir, '..'); + +const parseArgs = (argv) => { + const args = {}; + for (let index = 0; index < argv.length; index += 1) { + const item = argv[index]; + if (!item.startsWith('--')) { + throw new Error(`Unexpected argument: ${item}`); + } + const key = item.slice(2); + const value = argv[index + 1]; + if (!value || value.startsWith('--')) { + throw new Error(`Missing value for --${key}`); + } + args[key] = value; + index += 1; + } + return args; +}; + +const resolvePath = (value) => path.resolve(repoRoot, value); + +const readProjectJson = () => { + const projectJsonPath = path.join(repoRoot, '.vercel', 'project.json'); + if (!fs.existsSync(projectJsonPath)) { + return {}; + } + return JSON.parse(fs.readFileSync(projectJsonPath, 'utf8')); +}; + +const copyStaticFiles = (sourceDir, targetDir) => { + fs.mkdirSync(targetDir, { recursive: true }); + for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) { + if (entry.name === '.vercel' || entry.name === 'vercel.json') { + continue; + } + fs.cpSync(path.join(sourceDir, entry.name), path.join(targetDir, entry.name), { recursive: true }); + } +}; + +const headersToMap = (headers) => + Object.fromEntries(headers.map((header) => [header.key, header.value])); + +const routeSourceForHeader = (source) => { + if (source === '/') { + return '^/$'; + } + if (/^\/[A-Za-z0-9_.-]+$/.test(source)) { + return `^${source.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`; + } + return source; +}; + +const findAppDir = (unpackDir, version) => { + const expectedDir = path.join(unpackDir, `5chan-${version}-html`); + if (fs.existsSync(path.join(expectedDir, 'index.html'))) { + return expectedDir; + } + + if (fs.existsSync(path.join(unpackDir, 'index.html'))) { + return unpackDir; + } + + const candidates = fs + .readdirSync(unpackDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(unpackDir, entry.name)) + .filter((candidate) => fs.existsSync(path.join(candidate, 'index.html'))); + + if (candidates.length !== 1) { + throw new Error(`Could not find exactly one release app directory in ${unpackDir}`); + } + + return candidates[0]; +}; + +const writeBuildOutputConfig = (outputDir) => { + const repoConfig = JSON.parse(fs.readFileSync(path.join(repoRoot, 'vercel.json'), 'utf8')); + const routes = []; + + for (const headerRule of repoConfig.headers || []) { + routes.push({ + src: routeSourceForHeader(headerRule.source), + headers: headersToMap(headerRule.headers), + continue: true, + }); + } + + routes.push({ handle: 'filesystem' }); + + for (const rewriteRule of repoConfig.rewrites || []) { + routes.push({ + src: rewriteRule.source, + dest: rewriteRule.destination, + }); + } + + const buildOutputConfig = { + version: 3, + routes, + }; + + fs.writeFileSync(path.join(outputDir, 'config.json'), `${JSON.stringify(buildOutputConfig, null, 2)}\n`); +}; + +const writeProjectLink = (deployDir) => { + const project = readProjectJson(); + const orgId = process.env.VERCEL_ORG_ID || project.orgId; + const projectId = process.env.VERCEL_PROJECT_ID || project.projectId; + const projectName = process.env.VERCEL_PROJECT_NAME || project.projectName || '5chan'; + + if (!orgId || !projectId) { + throw new Error('VERCEL_ORG_ID and VERCEL_PROJECT_ID are required to link the staged deploy.'); + } + + const vercelDir = path.join(deployDir, '.vercel'); + fs.mkdirSync(vercelDir, { recursive: true }); + fs.writeFileSync( + path.join(vercelDir, 'project.json'), + `${JSON.stringify({ orgId, projectId, projectName }, null, 2)}\n`, + ); +}; + +const validateRelease = (appDir, version) => { + for (const file of ['index.html', 'version.json', '5chan-release-manifest.json', '5chan-release-manifest.sig.json']) { + if (!fs.existsSync(path.join(appDir, file))) { + throw new Error(`Release HTML archive is missing ${file}`); + } + } + + const versionJson = JSON.parse(fs.readFileSync(path.join(appDir, 'version.json'), 'utf8')); + if (versionJson.version !== version) { + throw new Error(`version.json has ${versionJson.version}, expected ${version}`); + } +}; + +const stagePrebuiltDeploy = (appDir, outDir) => { + const deployDir = path.join(outDir, 'prebuilt'); + const outputDir = path.join(deployDir, '.vercel', 'output'); + const staticDir = path.join(outputDir, 'static'); + + copyStaticFiles(appDir, staticDir); + writeBuildOutputConfig(outputDir); + writeProjectLink(deployDir); + + return deployDir; +}; + +const main = () => { + const args = parseArgs(process.argv.slice(2)); + const tag = args.tag || process.env.GITHUB_REF_NAME; + if (!tag?.startsWith('v')) { + throw new Error('Pass --tag vX.Y.Z or set GITHUB_REF_NAME to a release tag.'); + } + + const version = tag.slice(1); + const assetPath = resolvePath(args.asset || `release-assets/5chan-${version}-html.zip`); + if (!fs.existsSync(assetPath)) { + throw new Error(`Release HTML archive not found: ${assetPath}`); + } + + const outDir = args['out-dir'] ? resolvePath(args['out-dir']) : fs.mkdtempSync(path.join(os.tmpdir(), '5chan-vercel-release-')); + fs.rmSync(outDir, { recursive: true, force: true }); + fs.mkdirSync(outDir, { recursive: true }); + + const unpackDir = path.join(outDir, 'unpacked'); + fs.mkdirSync(unpackDir, { recursive: true }); + execFileSync('unzip', ['-q', assetPath, '-d', unpackDir], { stdio: 'inherit' }); + + const appDir = findAppDir(unpackDir, version); + validateRelease(appDir, version); + const deployDir = stagePrebuiltDeploy(appDir, outDir); + + console.error(`Prepared ${tag} release deployment from ${path.relative(repoRoot, assetPath)}`); + console.error(`Staged Vercel prebuilt directory: ${deployDir}`); + process.stdout.write(`${deployDir}\n`); +}; + +try { + main(); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}