diff --git a/.github/workflows/vercel-build-report.yml b/.github/workflows/vercel-build-report.yml index 91a64f6f1..624bec91f 100644 --- a/.github/workflows/vercel-build-report.yml +++ b/.github/workflows/vercel-build-report.yml @@ -1,22 +1,24 @@ 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. The full log is also attached to the Vercel Slack app's "failed -# to deploy" post when the SLACK_BOT_TOKEN secret and SLACK_CHANNEL_ID -# variable are set (see dev/slack-app-vercel-build-report.json). +# build fails, this attaches the build log to the Vercel Slack app's "failed +# to deploy" post (SLACK_BOT_TOKEN secret and SLACK_CHANNEL_ID variable; see +# dev/slack-app-vercel-build-report.json) and comments a link to it on the +# PR; when a later revision builds, the comment is updated to say so. The log +# never goes on the PR itself, so anything sensitive a build prints stays in +# Slack instead of a public repository. # # 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= +# -f id=dpl_... -f state=failed -f sha= on: repository_dispatch: - types: [vercel.deployment.error, vercel.deployment.success] + # A build that exits non-zero is `failed`; `error` is only sent for + # deleted deployments, which have no log + types: [vercel.deployment.failed, vercel.deployment.success] workflow_dispatch: inputs: id: @@ -26,7 +28,7 @@ on: description: Deployment state (client_payload.state.type) required: true type: choice - options: [error, success] + options: [failed, success] sha: description: Full commit SHA of the PR head (client_payload.git.sha) required: true @@ -34,8 +36,6 @@ 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 }} @@ -46,7 +46,12 @@ env: jobs: report: - if: github.event.client_payload.environment != 'production' + # `failed` is also sent for checks_failed, aliasing_failed and account + # problems, where the build log shows a build that passed + if: >- + github.event.client_payload.environment != 'production' + && (github.event.client_payload.state.type != 'failed' + || github.event.client_payload.state.detail == 'deployment_failed') runs-on: ubuntu-latest steps: - name: Check out dev/report-vercel-build.mjs @@ -57,35 +62,26 @@ jobs: - name: Fetch the build log from Vercel # Vercel is only contacted when the build failed - if: env.DEPLOYMENT_STATE == 'error' + if: env.DEPLOYMENT_STATE == 'failed' 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: - PR_NUMBER: ${{ steps.log.outputs.pull_request }} - ARTIFACT_ID: ${{ steps.artifact.outputs.artifact-id }} - ARTIFACT_URL: ${{ steps.artifact.outputs.artifact-url }} - run: node dev/report-vercel-build.mjs comment "$LOG_FILE" - - name: Attach the log to the Vercel app's Slack post - if: env.DEPLOYMENT_STATE == 'error' - # The PR comment is the record; a Slack problem must not fail it + if: env.DEPLOYMENT_STATE == 'failed' + id: slack + # The PR should still hear about the failure when Slack is down continue-on-error: true env: PR_NUMBER: ${{ steps.log.outputs.pull_request }} SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} SLACK_CHANNEL_ID: ${{ vars.SLACK_CHANNEL_ID }} run: node dev/report-vercel-build.mjs slack "$LOG_FILE" + + - name: Comment on the pull request + env: + PR_NUMBER: ${{ steps.log.outputs.pull_request }} + SLACK_PERMALINK: ${{ steps.slack.outputs.permalink }} + run: node dev/report-vercel-build.mjs comment diff --git a/AGENTS.md b/AGENTS.md index 674ff4307..ddb33dcd9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +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. It also attaches the full log to the Vercel Slack app's "failed to deploy" post in `#alerts-vercel-doc-site`, using the `SLACK_BOT_TOKEN` repo secret and `SLACK_CHANNEL_ID` repo variable. The bot is the Slack app in `dev/slack-app-vercel-build-report.json`; to recreate it, paste that manifest at (From a manifest), install it, copy its Bot User OAuth Token into the secret, and `/invite @Vercel build log` to the channel +- **Vercel build failures**: Vercel shows build logs only to its team members, so `.github/workflows/vercel-build-report.yml` attaches the log to the Vercel Slack app's "failed to deploy" post in `#alerts-vercel-doc-site` and comments a link to it on the PR (see `dev/report-vercel-build.mjs`). The log itself never goes on the PR, since the repository is public. 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. Slack needs the `SLACK_BOT_TOKEN` repo secret and `SLACK_CHANNEL_ID` repo variable. The bot is the Slack app in `dev/slack-app-vercel-build-report.json`; to recreate it, paste that manifest at (From a manifest), install it, copy its Bot User OAuth Token into the secret, and `/invite @Vercel build log` to the channel ## AI Chat Integration diff --git a/dev/report-vercel-build.mjs b/dev/report-vercel-build.mjs index 50f419687..caddf06d7 100644 --- a/dev/report-vercel-build.mjs +++ b/dev/report-vercel-build.mjs @@ -2,35 +2,35 @@ /** * 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. + * build logs to members of the Vercel team. The log itself goes to Slack, so + * anything sensitive a build prints stays inside the workspace instead of a + * public PR; the PR comment only links to it. 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] * node dev/report-vercel-build.mjs slack [--dry-run] + * node dev/report-vercel-build.mjs comment [--dry-run] * - * fetch-log writes the build log to , and to GITHUB_OUTPUT `truncated`, - * so the workflow can upload the full log as an artifact when the comment - * cannot hold all of it, and `pull_request`, the PR Vercel built the - * deployment for. It needs VERCEL_TOKEN, and VERCEL_TEAM_ID unless the token - * is scoped to the project. - * - * comment posts the tail of on PR_NUMBER, the PR Vercel built the - * deployment for. When unset (the success path, or a deployment Vercel - * recorded no PR for) it falls back to every open PR at COMMIT_SHA. It links - * 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. + * fetch-log writes the build log to , and `pull_request`, the PR Vercel + * built the deployment for, to GITHUB_OUTPUT. It needs VERCEL_TOKEN, and + * VERCEL_TEAM_ID unless the token is scoped to the project. * * slack uploads into the thread of the Vercel Slack app's "failed to - * deploy" post for the commit in SLACK_CHANNEL_ID, looking back 30 minutes - * and waiting up to 5 more for the post to appear. It needs SLACK_BOT_TOKEN - * (see dev/slack-app-vercel-build-report.json) and does nothing when that or - * SLACK_CHANNEL_ID is unset. With --dry-run the post is found but nothing is - * uploaded. + * deploy" post for the deployment in SLACK_CHANNEL_ID, looking back a week (so + * a re-run by hand still finds it) and waiting up to 5 minutes for the post + * to appear, then writes the reply's `permalink` to GITHUB_OUTPUT. It needs + * SLACK_BOT_TOKEN (see dev/slack-app-vercel-build-report.json) and does + * nothing when that or SLACK_CHANNEL_ID is unset. With --dry-run the post is + * found but nothing is uploaded. + * + * comment posts on PR_NUMBER, the PR Vercel built the deployment for. When + * unset (the success path, or a deployment Vercel recorded no PR for) it falls + * back to every open PR at COMMIT_SHA. A failure comment links + * SLACK_PERMALINK, or the channel when the upload did not happen. With + * --dry-run the comment is printed instead. * - * All need DEPLOYMENT_ID, DEPLOYMENT_STATE (error or success), COMMIT_SHA, + * All need DEPLOYMENT_ID, DEPLOYMENT_STATE (failed or success), COMMIT_SHA, * GH_TOKEN and GITHUB_REPOSITORY. */ @@ -40,10 +40,9 @@ 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 SLACK_HISTORY_MINUTES = 30; +// A week, so a re-run by hand finds the post; the deployment ID match is +// exact, so the wider window cannot pick a wrong post +const SLACK_HISTORY_DAYS = 7; const SLACK_WAIT_MINUTES = 5; const SLACK_POLL_SECONDS = 15; @@ -52,9 +51,9 @@ const REPOSITORY = process.env.GITHUB_REPOSITORY; const {DEPLOYMENT_ID, DEPLOYMENT_STATE, COMMIT_SHA, SLACK_CHANNEL_ID} = process.env; -// The artifact ID rides along in the marker so a later run can delete it -const MARKER = '/; +// Comments from before the log moved to Slack carry an artifact ID here +const MARKER = ''; +const MARKER_PATTERN = /^/; async function fetchJson(url, headers) { const response = await fetch(url, {headers}); @@ -108,6 +107,15 @@ async function fetchDeploymentPullRequestNumber() { const deployment = await fetchJson(url, { authorization: `Bearer ${process.env.VERCEL_TOKEN}` }); + // The deployment ID and commit arrive as separate inputs; only publish + // the log of the deployment Vercel built from that commit + const builtSha = + deployment.meta?.githubCommitSha ?? deployment.gitSource?.sha; + if (builtSha !== COMMIT_SHA) { + throw new Error( + `Deployment ${DEPLOYMENT_ID} was built from ${builtSha}, not ${COMMIT_SHA}` + ); + } return deployment.meta?.githubPrId; } @@ -165,9 +173,10 @@ async function fetchBuildLog() { .map(redact); } -// Credential shapes a build might print. The comment and artifact are public, -// and the build gets VERCEL_OIDC_TOKEN and friends, so a left-in -// `console.log(process.env)` must not publish them. Not a complete list. +// Credential shapes a build might print. The log only goes to Slack, but the +// build gets VERCEL_OIDC_TOKEN and friends, so a left-in +// `console.log(process.env)` should still not hand them to the whole channel. +// Not a complete list. // cspell:disable -- token prefixes, not words const REDACTION_PATTERNS = [ [/\beyJ[\w-]{10,}\.[\w-]{10,}\.[\w-]+/g, '[redacted-jwt]'], @@ -190,14 +199,10 @@ function redact(line) { ); } -// 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); +function writeOutput(name, value) { + if (process.env.GITHUB_OUTPUT) { + appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`); } - return tail; } async function fetchLog() { @@ -213,113 +218,52 @@ async function fetchLog() { const pullRequestNumber = await fetchDeploymentPullRequestNumber(); 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}\npull_request=${pullRequestNumber ?? ''}\n` - ); - } -} - -// A fence longer than any run of backticks in the log, so no log line can -// close it and inject Markdown into the comment -function fenceFor(lines) { - const longestRun = Math.max( - 2, - ...lines.flatMap(line => - (line.match(/`+/g) ?? []).map(run => run.length) - ) - ); - return '`'.repeat(longestRun + 1); + console.log(`Wrote ${logLines.length} log lines to ${logFile}`); + writeOutput('pull_request', pullRequestNumber ?? ''); } -function failureBody(logLines, artifact) { - const tail = tailOf(logLines); - const fence = fenceFor(tail); - const intro = - 'Vercel paywalls build logs to authorized users in its web UI, so'; - const message = artifact - ? `${intro} we tailed the last ${tail.length} lines of the build log for you here. 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.` - : `${intro} here is the build log.`; +// The log stays in Slack, where only the workspace can read it; the public +// comment says where to look +function failureBody() { + const {SLACK_PERMALINK} = process.env; + const where = SLACK_PERMALINK + ? `[attached to its Slack post](${SLACK_PERMALINK})` + : 'in Slack'; return [ - `${MARKER}${artifact ? ` artifact=${artifact.id}` : ''} -->`, + MARKER, '### ❌ The Vercel build failed for this PR', '', - message, - '', - '
', - 'Build log', - '', - fence, - ...tail, - fence, - '', - '
', + `Vercel only shows build logs to members of its team, so the build log is ${where} in #alerts-vercel-doc-site.`, '' ].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(process.env.PR_NUMBER); - 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); + await report(pull); } } // Comment only when the build failed, or an earlier failure is resolved -async function report(pull, logLines) { +async function report(pull) { 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} - ); + if (DEPLOYMENT_STATE === 'failed') { + body = failureBody(); } 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` @@ -359,37 +303,75 @@ async function slackApi(method, parameters) { return result; } -// The Vercel Slack app posts " failed to deploy … | -// " for each failed deployment. It and this workflow are triggered -// by the same event, so its post can land after this runs; keep looking for a -// while before giving up. +// Every string in a Slack message: the top-level text, plus legacy +// attachments and Block Kit blocks, where apps often put the real content +function slackMessageText(message) { + const strings = []; + const collect = value => { + if (typeof value === 'string') { + strings.push(value); + } else if (Array.isArray(value)) { + value.forEach(collect); + } else if (value && typeof value === 'object') { + Object.values(value).forEach(collect); + } + }; + collect([message.text, message.attachments, message.blocks]); + return strings.join('\n'); +} + +// The Vercel Slack app posts " failed to deploy" for each +// failed deployment, with the short SHA in a context block and an Inspect +// button whose URL ends in the deployment ID. The ID is matched, since two +// PRs at one commit get two deployments and two posts. The app and this +// workflow are triggered by the same event, so its post can land after this +// runs; keep looking for a while before giving up. async function findVercelFailurePost() { - const shortSha = COMMIT_SHA.slice(0, 7); - const oldest = Date.now() / 1000 - SLACK_HISTORY_MINUTES * 60; + const deploymentId = DEPLOYMENT_ID.replace(/^dpl_/, ''); + const oldest = Date.now() / 1000 - SLACK_HISTORY_DAYS * 24 * 60 * 60; const deadline = Date.now() + SLACK_WAIT_MINUTES * 60_000; for (;;) { - const {messages} = await slackApi('conversations.history', { - channel: SLACK_CHANNEL_ID, - oldest, - limit: 200 - }); - const post = messages.find( - message => - message.bot_id && - message.text?.includes('failed to deploy') && - message.text.includes(shortSha) - ); - if (post) { - return post; + // Newest first, a page at a time + const seen = []; + for (let cursor; ; ) { + const {messages, response_metadata: metadata} = await slackApi( + 'conversations.history', + { + channel: SLACK_CHANNEL_ID, + oldest, + limit: 200, + ...(cursor && {cursor}) + } + ); + const post = messages.find(message => { + const text = slackMessageText(message); + return ( + text.includes('failed to deploy') && + text.includes(deploymentId) + ); + }); + if (post) { + return post; + } + seen.push(...messages); + cursor = metadata?.next_cursor; + if (!cursor) { + break; + } } if (Date.now() >= deadline) { console.log( - `No Vercel "failed to deploy" post for ${shortSha} in the last ${SLACK_HISTORY_MINUTES} minutes; giving up` + `No Vercel "failed to deploy" post for ${DEPLOYMENT_ID} in the last ${SLACK_HISTORY_DAYS} days; giving up. Newest messages seen:` ); + for (const message of seen.slice(0, 20)) { + console.log( + ` ${message.ts} bot_id=${message.bot_id ?? '-'} user=${message.user ?? '-'} subtype=${message.subtype ?? '-'} ${JSON.stringify(slackMessageText(message).slice(0, 120))}` + ); + } return undefined; } console.log( - `No Vercel post for ${shortSha} yet; checking again in ${SLACK_POLL_SECONDS}s` + `No Vercel post for ${DEPLOYMENT_ID} yet; checking again in ${SLACK_POLL_SECONDS}s` ); await new Promise(resolve => setTimeout(resolve, SLACK_POLL_SECONDS * 1000) @@ -398,21 +380,23 @@ async function findVercelFailurePost() { } // Slack takes files in three steps: ask for an upload URL, POST the bytes to -// it, then say which channel and thread to share the file in +// it, then say which channel and thread to share the file in. Returns the +// permalink of the reply carrying the file, for the PR comment. async function uploadLogToThread(post, pulls) { const log = readFileSync(logFile); const shortSha = COMMIT_SHA.slice(0, 7); - const filename = `vercel-build-${shortSha}.log`; + // .txt, so Slack shows it inline instead of offering a download + const filename = `vercel-build-${shortSha}.txt`; const links = pulls .map(pull => `<${pull.html_url}|#${pull.number}>`) .join(', '); - const initialComment = `Build log attached; its tail is also commented on PR ${links}.`; + const initialComment = `Build log attached; PR ${links} links here.`; if (DRY_RUN) { console.log( `[dry-run] would upload ${filename} (${log.length} bytes) to thread ${post.ts} in ${SLACK_CHANNEL_ID}:\n${initialComment}` ); - return; + return undefined; } const {upload_url: uploadUrl, file_id: fileId} = await slackApi( 'files.getUploadURLExternal', @@ -435,6 +419,21 @@ async function uploadLogToThread(post, pulls) { console.log( `Uploaded ${filename} to thread ${post.ts} in ${SLACK_CHANNEL_ID}` ); + + // The upload response names only the file, so find the reply it made; + // fall back to the post itself rather than leave the PR without a link + const {messages} = await slackApi('conversations.replies', { + channel: SLACK_CHANNEL_ID, + ts: post.ts + }); + const reply = messages.find(message => + message.files?.some(file => file.id === fileId) + ); + const {permalink} = await slackApi('chat.getPermalink', { + channel: SLACK_CHANNEL_ID, + message_ts: reply?.ts ?? post.ts + }); + return permalink; } async function slack() { @@ -444,19 +443,22 @@ async function slack() { ); return; } - if (DEPLOYMENT_STATE !== 'error') { + if (DEPLOYMENT_STATE !== 'failed') { console.log('The build passed; Vercel already posts that to Slack'); return; } // The same guard as fetch-log and comment, so Slack only ever gets logs - // the PR comment also shows + // for commits an open PR from this repository is at const pulls = await findPullRequests(process.env.PR_NUMBER); if (pulls.length === 0) { return; } const post = await findVercelFailurePost(); if (post) { - await uploadLogToThread(post, pulls); + const permalink = await uploadLogToThread(post, pulls); + if (permalink) { + writeOutput('permalink', permalink); + } } } @@ -472,25 +474,21 @@ async function main() { throw new Error(`Missing required environment variable ${name}`); } } - if (!['error', 'success'].includes(DEPLOYMENT_STATE)) { + if (!['failed', '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|slack ' - ); - } - - if (command === 'fetch-log') { - await fetchLog(); - } else if (command === 'comment') { + const usage = + 'Usage: node dev/report-vercel-build.mjs fetch-log|slack , or comment'; + if (command === 'comment') { await comment(); + } else if (!logFile) { + throw new Error(usage); + } else if (command === 'fetch-log') { + await fetchLog(); } else if (command === 'slack') { await slack(); } else { - throw new Error( - `Unknown command ${command}; use fetch-log, comment or slack` - ); + throw new Error(usage); } }