diff --git a/.github/workflows/vercel-build-report.yml b/.github/workflows/vercel-build-report.yml new file mode 100644 index 000000000..ce1add6f7 --- /dev/null +++ b/.github/workflows/vercel-build-report.yml @@ -0,0 +1,78 @@ +name: Vercel build report + +# Vercel only shows build logs to members of its team. When a PR's Vercel +# build fails, this comments the end of the build log on the PR, with the +# full log as a workflow artifact when the comment cannot hold it all; when a +# later revision builds, the comment is updated to say so and the artifact +# is deleted. +# +# GitHub only delivers repository_dispatch (and finds workflow_dispatch +# workflows) once the workflow file is on the default branch, so before merge +# run dev/report-vercel-build.mjs locally instead. After merge, re-run on a PR +# by hand with the same payload fields as inputs: +# gh workflow run vercel-build-report.yml \ +# -f id=dpl_... -f state=error -f sha= +on: + repository_dispatch: + types: [vercel.deployment.error, vercel.deployment.success] + workflow_dispatch: + inputs: + id: + description: Vercel deployment ID (client_payload.id) + required: true + state: + description: Deployment state (client_payload.state.type) + required: true + type: choice + options: [error, success] + sha: + description: Full commit SHA of the PR head (client_payload.git.sha) + required: true + +permissions: + contents: read + pull-requests: write + # To delete the full-log artifact once the build passes + actions: write + +env: + DEPLOYMENT_ID: ${{ github.event.client_payload.id || inputs.id }} + DEPLOYMENT_STATE: ${{ github.event.client_payload.state.type || inputs.state }} + COMMIT_SHA: ${{ github.event.client_payload.git.sha || inputs.sha }} + GH_TOKEN: ${{ github.token }} + LOG_FILE: ${{ github.workspace }}/vercel-build.log + +jobs: + report: + if: github.event.client_payload.environment != 'production' + runs-on: ubuntu-latest + steps: + - name: Check out dev/report-vercel-build.mjs + uses: actions/checkout@v4 + with: + sparse-checkout: dev/report-vercel-build.mjs + sparse-checkout-cone-mode: false + + - name: Fetch the build log from Vercel + # Vercel is only contacted when the build failed + if: env.DEPLOYMENT_STATE == 'error' + id: log + env: + # Scoped to the sourcegraph-docs project, so it needs no team ID + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + run: node dev/report-vercel-build.mjs fetch-log "$LOG_FILE" + + - name: Attach the full log when the comment cannot hold it all + if: steps.log.outputs.truncated == 'true' + id: artifact + uses: actions/upload-artifact@v4 + with: + name: vercel-build-log-${{ env.COMMIT_SHA }} + path: ${{ env.LOG_FILE }} + retention-days: 30 + + - name: Comment on the pull request + env: + ARTIFACT_ID: ${{ steps.artifact.outputs.artifact-id }} + ARTIFACT_URL: ${{ steps.artifact.outputs.artifact-url }} + run: node dev/report-vercel-build.mjs comment "$LOG_FILE" diff --git a/AGENTS.md b/AGENTS.md index 580711718..96bcf7879 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,7 @@ - **Checks**: `npm run check` runs every `dev/check-*.mjs` (links, filenames, images); `npm run build` runs them first, so any finding fails a deploy - **Check links**: `npm run check -- links --check-anchors --check-self-links` (CI comments on PRs that break links; see `dev/check-links.mjs`; the build runs it without flags, so only dead page links fail a deploy). When moving a page or renaming a heading, update every link to it; a redirect in `src/data/redirects.ts` does not satisfy the check. Link to this site with relative paths (`/admin/config/site-config`), never `https://sourcegraph.com/docs/…` or `https://docs.sourcegraph.com/…`. To also probe the external links you added: `npm run check -- links --check-anchors --check-self-links --check-external --diff <(git diff -U0 origin/main)` - **Prove changed links resolve on a deploy**: `node dev/verify-links-live.mjs --site ` prints a Markdown table for the PR description +- **Vercel build failures**: Vercel shows build logs only to its team members, so `.github/workflows/vercel-build-report.yml` comments the log tail on the PR (see `dev/report-vercel-build.mjs`). It reads Vercel with the `VERCEL_TOKEN` repo secret, a token scoped to the `sourcegraph-docs` project that expires 2026-12-10; mint a new one with `POST /v3/user/tokens?teamId=` and `projectId` in the body ## AI Chat Integration diff --git a/dev/report-vercel-build.mjs b/dev/report-vercel-build.mjs new file mode 100644 index 000000000..a9fc6609c --- /dev/null +++ b/dev/report-vercel-build.mjs @@ -0,0 +1,293 @@ +#!/usr/bin/env node + +/** + * Reports a failed Vercel build on its pull request, since Vercel only shows + * build logs to members of the Vercel team. When a later revision builds, the + * same comment is updated to say so. + * + * Usage: + * node dev/report-vercel-build.mjs fetch-log + * node dev/report-vercel-build.mjs comment [--dry-run] + * + * fetch-log writes the build log to , and `truncated` to GITHUB_OUTPUT, + * so the workflow can upload the full log as an artifact when the comment + * cannot hold all of it. It needs VERCEL_TOKEN, and VERCEL_TEAM_ID unless the + * token is scoped to the project. + * + * comment posts the tail of , linking the artifact from ARTIFACT_ID and + * ARTIFACT_URL when set, and deletes the artifact an earlier comment linked. + * With --dry-run the comment is printed instead, and nothing is deleted. + * + * Both need DEPLOYMENT_ID, DEPLOYMENT_STATE (error or success), COMMIT_SHA, + * GH_TOKEN and GITHUB_REPOSITORY. + */ + +import {appendFileSync, readFileSync, writeFileSync} from 'fs'; + +const [command, logFile] = process.argv + .slice(2) + .filter(argument => !argument.startsWith('--')); +const DRY_RUN = process.argv.includes('--dry-run'); +const MAX_LOG_LINES = 100; +const MAX_LOG_CHARS = 30_000; +const ARTIFACT_RETENTION_DAYS = 30; + +const API_URL = process.env.GITHUB_API_URL ?? 'https://api.github.com'; +const REPOSITORY = process.env.GITHUB_REPOSITORY; +const {DEPLOYMENT_ID, DEPLOYMENT_STATE, COMMIT_SHA} = process.env; + +// The artifact ID rides along in the marker so a later run can delete it +const MARKER = '/; + +async function fetchJson(url, headers) { + const response = await fetch(url, {headers}); + if (!response.ok) { + throw new Error( + `GET ${url} failed: ${response.status} ${await response.text()}` + ); + } + return response.json(); +} + +async function github(method, route, body) { + const response = await fetch(`${API_URL}${route}`, { + method, + headers: { + authorization: `Bearer ${process.env.GH_TOKEN}`, + accept: 'application/vnd.github+json', + 'x-github-api-version': '2022-11-28', + ...(body && {'content-type': 'application/json'}) + }, + body: body && JSON.stringify(body) + }); + if (!response.ok) { + throw new Error( + `${method} ${route} failed: ${response.status} ${await response.text()}` + ); + } + return response.status === 204 ? undefined : response.json(); +} + +async function githubList(route) { + const items = []; + for (let page = 1; ; page++) { + const batch = await github('GET', `${route}?per_page=100&page=${page}`); + items.push(...batch); + if (batch.length < 100) { + return items; + } + } +} + +// The dispatch payload has no PR number; look up the PRs from the commit. A +// deployment belongs to a commit, so every open PR at that head gets the +// report. A stale event for a commit a PR has moved past is ignored. Fork PRs +// are ignored too, so the Vercel token is only ever used for commits by +// people who can already push to this repository. +async function findPullRequests() { + const pulls = await github( + 'GET', + `/repos/${REPOSITORY}/commits/${COMMIT_SHA}/pulls` + ); + const open = pulls.filter(pull => { + if (pull.state !== 'open' || pull.head.sha !== COMMIT_SHA) { + return false; + } + if (pull.head.repo.full_name !== REPOSITORY) { + console.log(`PR #${pull.number} is from a fork; not reporting`); + return false; + } + return true; + }); + if (open.length === 0) { + console.log(`No open PR with head ${COMMIT_SHA}; nothing to do`); + } + return open; +} + +// Build log lines, oldest first. Vercel keeps them as events; only the ones +// with text are log lines. +async function fetchBuildLog() { + const url = new URL( + `https://api.vercel.com/v3/deployments/${DEPLOYMENT_ID}/events` + ); + url.searchParams.set('limit', '-1'); + url.searchParams.set('direction', 'forward'); + if (process.env.VERCEL_TEAM_ID) { + url.searchParams.set('teamId', process.env.VERCEL_TEAM_ID); + } + const events = await fetchJson(url, { + authorization: `Bearer ${process.env.VERCEL_TOKEN}` + }); + return events + .map(event => event.payload?.text ?? event.text) + .filter(text => typeof text === 'string') + .flatMap(text => text.replace(/\n$/, '').split('\n')); +} + +// The failure is at the end of the log; keep the tail within GitHub's +// comment size limit +function tailOf(logLines) { + let tail = logLines.slice(-MAX_LOG_LINES); + while (tail.length > 1 && tail.join('\n').length > MAX_LOG_CHARS) { + tail = tail.slice(1); + } + return tail; +} + +async function fetchLog() { + if (!process.env.VERCEL_TOKEN) { + throw new Error('VERCEL_TOKEN is required to read the build log'); + } + if ((await findPullRequests()).length === 0) { + return; + } + const logLines = await fetchBuildLog(); + writeFileSync(logFile, logLines.join('\n') + '\n'); + const truncated = tailOf(logLines).length < logLines.length; + console.log( + `Wrote ${logLines.length} log lines to ${logFile}${truncated ? '; the comment will show the tail' : ''}` + ); + if (process.env.GITHUB_OUTPUT) { + appendFileSync(process.env.GITHUB_OUTPUT, `truncated=${truncated}\n`); + } +} + +// A four-backtick fence so lines containing ``` cannot break out of the block +function failureBody(logLines, artifact) { + const tail = tailOf(logLines); + const fullLog = artifact + ? `The full log is ${logLines.length} lines, attached as a [workflow artifact](${artifact.url}); downloading it needs a GitHub login, and it expires in ${ARTIFACT_RETENTION_DAYS} days.` + : `The full log is ${logLines.length} lines.`; + return [ + `${MARKER}${artifact ? ` artifact=${artifact.id}` : ''} -->`, + '### ❌ The Vercel build failed for this PR', + '', + `Vercel paywalls build logs to authorized users in its web UI, so we tailed the last ${MAX_LOG_LINES} lines of the build log for you here. ${fullLog}`, + '', + '
', + 'Build log', + '', + '````', + ...tail, + '````', + '', + '
', + '' + ].join('\n'); +} + +async function deleteArtifact(id) { + console.log(`${DRY_RUN ? '[dry-run] ' : ''}Deleting artifact ${id}`); + if (DRY_RUN) { + return; + } + try { + await github('DELETE', `/repos/${REPOSITORY}/actions/artifacts/${id}`); + } catch (error) { + // Already expired or deleted + if (!error.message.includes(' 404 ')) { + throw error; + } + } +} + +async function comment() { + const pulls = await findPullRequests(); + if (pulls.length === 0) { + return; + } + let logLines; + if (DEPLOYMENT_STATE === 'error') { + logLines = readFileSync(logFile, 'utf8').replace(/\n$/, '').split('\n'); + } + for (const pull of pulls) { + await report(pull, logLines); + } +} + +// Comment only when the build failed, or an earlier failure is resolved +async function report(pull, logLines) { + const comments = await githubList( + `/repos/${REPOSITORY}/issues/${pull.number}/comments` + ); + const existing = comments.find(comment => + MARKER_PATTERN.test(comment.body) + ); + const previousArtifact = existing?.body.match(MARKER_PATTERN)[1]; + + let body; + if (logLines) { + const {ARTIFACT_ID, ARTIFACT_URL} = process.env; + body = failureBody( + logLines, + ARTIFACT_ID && {id: ARTIFACT_ID, url: ARTIFACT_URL} + ); + } else if (existing) { + body = `${MARKER} -->\n### ✅ The Vercel build that failed on an earlier revision of this PR passes\n`; + } else { + console.log(`PR #${pull.number} has no failed build to resolve`); + return; + } + + if (previousArtifact) { + await deleteArtifact(previousArtifact); + } + + if (DRY_RUN) { + console.log( + `[dry-run] would ${existing ? 'update' : 'create'} comment on PR #${pull.number}:\n` + ); + console.log(body); + } else if (existing) { + console.log(`Updating comment ${existing.id} on PR #${pull.number}`); + await github( + 'PATCH', + `/repos/${REPOSITORY}/issues/comments/${existing.id}`, + {body} + ); + } else { + console.log(`Commenting on PR #${pull.number}`); + await github( + 'POST', + `/repos/${REPOSITORY}/issues/${pull.number}/comments`, + {body} + ); + } +} + +async function main() { + for (const name of [ + 'DEPLOYMENT_ID', + 'DEPLOYMENT_STATE', + 'COMMIT_SHA', + 'GH_TOKEN', + 'GITHUB_REPOSITORY' + ]) { + if (!process.env[name]) { + throw new Error(`Missing required environment variable ${name}`); + } + } + if (!['error', 'success'].includes(DEPLOYMENT_STATE)) { + throw new Error(`Unexpected DEPLOYMENT_STATE ${DEPLOYMENT_STATE}`); + } + if (!logFile) { + throw new Error( + 'Usage: node dev/report-vercel-build.mjs fetch-log|comment ' + ); + } + + if (command === 'fetch-log') { + await fetchLog(); + } else if (command === 'comment') { + await comment(); + } else { + throw new Error(`Unknown command ${command}; use fetch-log or comment`); + } +} + +main().catch(error => { + console.error(error); + process.exit(2); +});