Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions .github/workflows/vercel-build-report.yml
Original file line number Diff line number Diff line change
@@ -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=<pr head 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"
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <vercel-preview-url>` 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=<team>` and `projectId` in the body

## AI Chat Integration

Expand Down
293 changes: 293 additions & 0 deletions dev/report-vercel-build.mjs
Original file line number Diff line number Diff line change
@@ -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 <file>
* node dev/report-vercel-build.mjs comment <file> [--dry-run]
*
* fetch-log writes the build log to <file>, 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 <file>, 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 = '<!-- vercel-build-report';
const MARKER_PATTERN = /^<!-- vercel-build-report(?: artifact=(\d+))? -->/;

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}`,
'',
'<details>',
'<summary>Build log</summary>',
'',
'````',
...tail,
'````',
'',
'</details>',
''
].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 <file>'
);
}

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);
});
Loading