From c522b86c9b9150d5584e7080df33bc8d8d232dca Mon Sep 17 00:00:00 2001 From: Marc LeBlanc <7050295+marcleblanc2@users.noreply.github.com> Date: Fri, 11 Sep 2026 05:05:07 -0600 Subject: [PATCH 1/4] ci: Comment the Vercel build log on PRs whose build fails Vercel shows build logs only to members of its team, so contributors saw a red X and a login wall. On vercel.deployment.error this posts the tail of the build log on the PR; on the next successful build the same comment is updated to say the failure is fixed. Fork PRs are skipped so the project-scoped Vercel token is never used on their behalf. --- .github/workflows/vercel-build-report.yml | 52 ++++++ AGENTS.md | 1 + dev/report-vercel-build.mjs | 198 ++++++++++++++++++++++ 3 files changed, 251 insertions(+) create mode 100644 .github/workflows/vercel-build-report.yml create mode 100644 dev/report-vercel-build.mjs diff --git a/.github/workflows/vercel-build-report.yml b/.github/workflows/vercel-build-report.yml new file mode 100644 index 000000000..576e3476b --- /dev/null +++ b/.github/workflows/vercel-build-report.yml @@ -0,0 +1,52 @@ +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; when a later +# revision builds, the comment is updated to say so. +# +# GitHub only delivers repository_dispatch to the workflow file on the default +# branch, so workflow_dispatch takes the same payload fields as inputs for +# testing before merge and for re-running on a PR by hand: +# gh workflow run vercel-build-report.yml --ref \ +# -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 + +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: Comment on the pull request + env: + GH_TOKEN: ${{ github.token }} + 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 }} + # Scoped to the sourcegraph-docs project, so it needs no team ID + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + run: node dev/report-vercel-build.mjs 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..3f9f16f10 --- /dev/null +++ b/dev/report-vercel-build.mjs @@ -0,0 +1,198 @@ +#!/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 [--dry-run] + * + * Requires DEPLOYMENT_ID, DEPLOYMENT_STATE (error or success), COMMIT_SHA, + * GH_TOKEN and GITHUB_REPOSITORY. A failed build also needs VERCEL_TOKEN, and + * VERCEL_TEAM_ID unless the token is scoped to the project. + * With --dry-run the comment is printed instead of posted. + */ + +const DRY_RUN = process.argv.includes('--dry-run'); +const MAX_LOG_LINES = 100; +const MAX_LOG_CHARS = 30_000; + +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; + +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.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 it up from the commit. A stale +// event for a commit the 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 findPullRequest() { + const pulls = await github( + 'GET', + `/repos/${REPOSITORY}/commits/${COMMIT_SHA}/pulls` + ); + const pull = pulls.find( + pull => pull.state === 'open' && pull.head.sha === COMMIT_SHA + ); + if (pull && pull.head.repo.full_name !== REPOSITORY) { + console.log(`PR #${pull.number} is from a fork; not reporting`); + return undefined; + } + return pull; +} + +// 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. A four-backtick fence so lines containing ``` cannot break out. +function failureBody(logLines) { + let tail = logLines.slice(-MAX_LOG_LINES); + while (tail.length > 1 && tail.join('\n').length > MAX_LOG_CHARS) { + tail = tail.slice(1); + } + const omitted = logLines.length - tail.length; + return [ + MARKER, + '### ❌ The Vercel build failed for this PR', + '', + 'Vercel only shows build logs to members of its team, so here is the end of the log.', + 'Run `npm run build` locally to reproduce.', + '', + '
', + `Build log${omitted > 0 ? ` (last ${tail.length} of ${logLines.length} lines)` : ''}`, + '', + '````', + ...tail, + '````', + '', + '
', + '' + ].join('\n'); +} + +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}`); + } + + const pull = await findPullRequest(); + if (!pull) { + console.log(`No open PR with head ${COMMIT_SHA}; nothing to do`); + return; + } + + const comments = await githubList( + `/repos/${REPOSITORY}/issues/${pull.number}/comments` + ); + const existing = comments.find(comment => comment.body.startsWith(MARKER)); + + // Comment only when the build failed, or an earlier failure is resolved + let body; + if (DEPLOYMENT_STATE === 'error') { + if (!process.env.VERCEL_TOKEN) { + throw new Error('VERCEL_TOKEN is required to read the build log'); + } + body = failureBody(await fetchBuildLog()); + } 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 (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} + ); + } +} + +main().catch(error => { + console.error(error); + process.exit(2); +}); From b3f814349cd3aea4546e1aa618f6192f3acc5907 Mon Sep 17 00:00:00 2001 From: Marc LeBlanc <7050295+marcleblanc2@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:41:23 -0600 Subject: [PATCH 2/4] vercel-build-report: report on every open PR at the commit; workflow_dispatch needs the file on main Two open PRs at the same head SHA got one comment on whichever PR the commits/{sha}/pulls API listed first. A deployment belongs to a commit, so comment on each open PR at that head, fetching the build log once. GitHub only resolves workflow_dispatch for workflows on the default branch (gh workflow run --ref 404s before merge), so the header now says to run the script locally until then. Amp-Thread-ID: https://ampcode.com/threads/T-01a08fee-74b4-76dc-aaf9-d1245d68fdc9 Co-authored-by: Amp --- .github/workflows/vercel-build-report.yml | 9 ++-- dev/report-vercel-build.mjs | 55 ++++++++++++++--------- 2 files changed, 39 insertions(+), 25 deletions(-) diff --git a/.github/workflows/vercel-build-report.yml b/.github/workflows/vercel-build-report.yml index 576e3476b..743daf74f 100644 --- a/.github/workflows/vercel-build-report.yml +++ b/.github/workflows/vercel-build-report.yml @@ -4,10 +4,11 @@ name: Vercel build report # build fails, this comments the end of the build log on the PR; when a later # revision builds, the comment is updated to say so. # -# GitHub only delivers repository_dispatch to the workflow file on the default -# branch, so workflow_dispatch takes the same payload fields as inputs for -# testing before merge and for re-running on a PR by hand: -# gh workflow run vercel-build-report.yml --ref \ +# 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: diff --git a/dev/report-vercel-build.mjs b/dev/report-vercel-build.mjs index 3f9f16f10..85981a32a 100644 --- a/dev/report-vercel-build.mjs +++ b/dev/report-vercel-build.mjs @@ -63,23 +63,26 @@ async function githubList(route) { } } -// The dispatch payload has no PR number; look it up from the commit. A stale -// event for a commit the 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 findPullRequest() { +// 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 pull = pulls.find( - pull => pull.state === 'open' && pull.head.sha === COMMIT_SHA - ); - if (pull && pull.head.repo.full_name !== REPOSITORY) { - console.log(`PR #${pull.number} is from a fork; not reporting`); - return undefined; - } - return pull; + return 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; + }); } // Build log lines, oldest first. Vercel keeps them as events; only the ones @@ -145,24 +148,34 @@ async function main() { throw new Error(`Unexpected DEPLOYMENT_STATE ${DEPLOYMENT_STATE}`); } - const pull = await findPullRequest(); - if (!pull) { + const pulls = await findPullRequests(); + if (pulls.length === 0) { console.log(`No open PR with head ${COMMIT_SHA}; nothing to do`); return; } + let logLines; + if (DEPLOYMENT_STATE === 'error') { + if (!process.env.VERCEL_TOKEN) { + throw new Error('VERCEL_TOKEN is required to read the build log'); + } + logLines = await fetchBuildLog(); + } + 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 => comment.body.startsWith(MARKER)); - // Comment only when the build failed, or an earlier failure is resolved let body; - if (DEPLOYMENT_STATE === 'error') { - if (!process.env.VERCEL_TOKEN) { - throw new Error('VERCEL_TOKEN is required to read the build log'); - } - body = failureBody(await fetchBuildLog()); + if (logLines) { + body = failureBody(logLines); } else if (existing) { body = `${MARKER}\n### ✅ The Vercel build that failed on an earlier revision of this PR passes\n`; } else { From f254f182ee9e4b0200b450c10dae26a1c69bbaae Mon Sep 17 00:00:00 2001 From: Marc LeBlanc <7050295+marcleblanc2@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:46:51 -0600 Subject: [PATCH 3/4] ci/vercel-build-report: Reword the failure comment Amp-Thread-ID: https://ampcode.com/threads/T-01a09014-dfa8-740c-95b4-9e28c43cae51 Co-authored-by: Amp --- dev/report-vercel-build.mjs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/dev/report-vercel-build.mjs b/dev/report-vercel-build.mjs index 85981a32a..9b6aa6cbe 100644 --- a/dev/report-vercel-build.mjs +++ b/dev/report-vercel-build.mjs @@ -117,11 +117,10 @@ function failureBody(logLines) { MARKER, '### ❌ The Vercel build failed for this PR', '', - 'Vercel only shows build logs to members of its team, so here is the end of the log.', - 'Run `npm run build` locally to reproduce.', + `Vercel paywalls build logs to authorized users in its web UI, so we tailed the last ${tail.length} lines of the build log for you here.`, '', '
', - `Build log${omitted > 0 ? ` (last ${tail.length} of ${logLines.length} lines)` : ''}`, + `Build log${omitted > 0 ? ` (${omitted} earlier lines omitted)` : ''}`, '', '````', ...tail, From a35c715334236b527db807a22b99deb347472c81 Mon Sep 17 00:00:00 2001 From: Marc LeBlanc <7050295+marcleblanc2@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:01:42 -0600 Subject: [PATCH 4/4] ci/vercel-build-report: Attach the full log as an artifact when the comment cannot hold it The comment says how many lines the log has and that the last 100 are shown, matching tail -n 100. When that leaves lines out, the full log is uploaded as a workflow artifact and linked from the comment; the artifact ID rides in the comment marker so the run that reports the build passing deletes it. Amp-Thread-ID: https://ampcode.com/threads/T-01a09014-dfa8-740c-95b4-9e28c43cae51 Co-authored-by: Amp --- .github/workflows/vercel-build-report.yml | 41 ++++-- dev/report-vercel-build.mjs | 157 +++++++++++++++++----- 2 files changed, 153 insertions(+), 45 deletions(-) diff --git a/.github/workflows/vercel-build-report.yml b/.github/workflows/vercel-build-report.yml index 743daf74f..ce1add6f7 100644 --- a/.github/workflows/vercel-build-report.yml +++ b/.github/workflows/vercel-build-report.yml @@ -1,8 +1,10 @@ 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; when a later -# revision builds, the comment is updated to say so. +# 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 @@ -30,6 +32,15 @@ on: 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: @@ -42,12 +53,26 @@ jobs: sparse-checkout: dev/report-vercel-build.mjs sparse-checkout-cone-mode: false - - name: Comment on the pull request + - name: Fetch the build log from Vercel + # Vercel is only contacted when the build failed + if: env.DEPLOYMENT_STATE == 'error' + id: log env: - GH_TOKEN: ${{ github.token }} - 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 }} # Scoped to the sourcegraph-docs project, so it needs no team ID VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} - run: node dev/report-vercel-build.mjs + 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/dev/report-vercel-build.mjs b/dev/report-vercel-build.mjs index 9b6aa6cbe..a9fc6609c 100644 --- a/dev/report-vercel-build.mjs +++ b/dev/report-vercel-build.mjs @@ -5,23 +5,40 @@ * 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 [--dry-run] + * Usage: + * node dev/report-vercel-build.mjs fetch-log + * node dev/report-vercel-build.mjs comment [--dry-run] * - * Requires DEPLOYMENT_ID, DEPLOYMENT_STATE (error or success), COMMIT_SHA, - * GH_TOKEN and GITHUB_REPOSITORY. A failed build also needs VERCEL_TOKEN, and - * VERCEL_TEAM_ID unless the token is scoped to the project. - * With --dry-run the comment is printed instead of posted. + * 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; -const MARKER = ''; +// 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}); @@ -49,7 +66,7 @@ async function github(method, route, body) { `${method} ${route} failed: ${response.status} ${await response.text()}` ); } - return response.json(); + return response.status === 204 ? undefined : response.json(); } async function githubList(route) { @@ -73,7 +90,7 @@ async function findPullRequests() { 'GET', `/repos/${REPOSITORY}/commits/${COMMIT_SHA}/pulls` ); - return pulls.filter(pull => { + const open = pulls.filter(pull => { if (pull.state !== 'open' || pull.head.sha !== COMMIT_SHA) { return false; } @@ -83,6 +100,10 @@ async function findPullRequests() { } 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 @@ -105,22 +126,48 @@ async function fetchBuildLog() { .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. A four-backtick fence so lines containing ``` cannot break out. -function failureBody(logLines) { +// 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); } - const omitted = logLines.length - tail.length; + 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, + `${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 ${tail.length} lines of the build log for you here.`, + `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${omitted > 0 ? ` (${omitted} earlier lines omitted)` : ''}`, + 'Build log', '', '````', ...tail, @@ -131,34 +178,29 @@ function failureBody(logLines) { ].join('\n'); } -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}`); - } +async function deleteArtifact(id) { + console.log(`${DRY_RUN ? '[dry-run] ' : ''}Deleting artifact ${id}`); + if (DRY_RUN) { + return; } - if (!['error', 'success'].includes(DEPLOYMENT_STATE)) { - throw new Error(`Unexpected DEPLOYMENT_STATE ${DEPLOYMENT_STATE}`); + 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) { - console.log(`No open PR with head ${COMMIT_SHA}; nothing to do`); return; } - let logLines; if (DEPLOYMENT_STATE === 'error') { - if (!process.env.VERCEL_TOKEN) { - throw new Error('VERCEL_TOKEN is required to read the build log'); - } - logLines = await fetchBuildLog(); + logLines = readFileSync(logFile, 'utf8').replace(/\n$/, '').split('\n'); } for (const pull of pulls) { await report(pull, logLines); @@ -170,18 +212,29 @@ async function report(pull, logLines) { const comments = await githubList( `/repos/${REPOSITORY}/issues/${pull.number}/comments` ); - const existing = comments.find(comment => comment.body.startsWith(MARKER)); + const existing = comments.find(comment => + MARKER_PATTERN.test(comment.body) + ); + const previousArtifact = existing?.body.match(MARKER_PATTERN)[1]; let body; if (logLines) { - body = failureBody(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`; + 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` @@ -204,6 +257,36 @@ async function report(pull, logLines) { } } +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);