From eb982be3d47e4117fd21bc17080c984b24d47d25 Mon Sep 17 00:00:00 2001 From: Marc LeBlanc <7050295+marcleblanc2@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:11:52 -0600 Subject: [PATCH 1/4] ci: Report Vercel build failures on the failed dispatch event, and find the Slack post by any text Vercel sends vercel.deployment.failed (state.detail deployment_failed) for a build that exits non-zero; vercel.deployment.error is only for deleted deployments, so the workflow never ran on a real build failure. fetch-log now also checks the deployment was built from COMMIT_SHA, since the two arrive as separate inputs. The Slack step could not find the Vercel app's post for b241772 even though it was in the window; match on text, attachments and blocks without requiring bot_id, and list the messages seen when giving up. Amp-Thread-ID: https://ampcode.com/threads/T-01a093c3-a827-71ba-af20-c13e851c9a77 Co-authored-by: Amp --- .github/workflows/vercel-build-report.yml | 19 ++++++--- dev/report-vercel-build.mjs | 51 ++++++++++++++++++----- 2 files changed, 53 insertions(+), 17 deletions(-) diff --git a/.github/workflows/vercel-build-report.yml b/.github/workflows/vercel-build-report.yml index 91a64f6f1..e64e343b1 100644 --- a/.github/workflows/vercel-build-report.yml +++ b/.github/workflows/vercel-build-report.yml @@ -13,10 +13,12 @@ name: Vercel build report # 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 @@ -46,7 +48,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,7 +64,7 @@ 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 @@ -81,7 +88,7 @@ jobs: 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' + if: env.DEPLOYMENT_STATE == 'failed' # The PR comment is the record; a Slack problem must not fail it continue-on-error: true env: diff --git a/dev/report-vercel-build.mjs b/dev/report-vercel-build.mjs index 50f419687..92be937b4 100644 --- a/dev/report-vercel-build.mjs +++ b/dev/report-vercel-build.mjs @@ -30,7 +30,7 @@ * SLACK_CHANNEL_ID is unset. With --dry-run the post is found but nothing is * uploaded. * - * 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. */ @@ -108,6 +108,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; } @@ -284,7 +293,7 @@ async function comment() { return; } let logLines; - if (DEPLOYMENT_STATE === 'error') { + if (DEPLOYMENT_STATE === 'failed') { logLines = readFileSync(logFile, 'utf8').replace(/\n$/, '').split('\n'); } for (const pull of pulls) { @@ -359,6 +368,23 @@ async function slackApi(method, parameters) { return result; } +// 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. It and this workflow are triggered // by the same event, so its post can land after this runs; keep looking for a @@ -373,19 +399,22 @@ async function findVercelFailurePost() { oldest, limit: 200 }); - const post = messages.find( - message => - message.bot_id && - message.text?.includes('failed to deploy') && - message.text.includes(shortSha) - ); + const post = messages.find(message => { + const text = slackMessageText(message); + return text.includes('failed to deploy') && text.includes(shortSha); + }); if (post) { return post; } 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 ${shortSha} in the last ${SLACK_HISTORY_MINUTES} minutes; giving up. Messages seen:` ); + for (const message of messages) { + 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( @@ -444,7 +473,7 @@ async function slack() { ); return; } - if (DEPLOYMENT_STATE !== 'error') { + if (DEPLOYMENT_STATE !== 'failed') { console.log('The build passed; Vercel already posts that to Slack'); return; } @@ -472,7 +501,7 @@ 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) { From aa2cfcb811637f0c0005b69961d60912b4e6ca00 Mon Sep 17 00:00:00 2001 From: Marc LeBlanc <7050295+marcleblanc2@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:27:40 -0600 Subject: [PATCH 2/4] ci: Find the Vercel Slack post by deployment ID The post's top-level text is the commit title plus 'failed to deploy'; the short SHA is only in a context block, which is why the SHA match missed. The Inspect button URL ends in the deployment ID, which is also unique per PR when two PRs share a commit. --- dev/report-vercel-build.mjs | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/dev/report-vercel-build.mjs b/dev/report-vercel-build.mjs index 92be937b4..a4766cdc9 100644 --- a/dev/report-vercel-build.mjs +++ b/dev/report-vercel-build.mjs @@ -385,12 +385,14 @@ function slackMessageText(message) { return strings.join('\n'); } -// 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. +// 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 deploymentId = DEPLOYMENT_ID.replace(/^dpl_/, ''); const oldest = Date.now() / 1000 - SLACK_HISTORY_MINUTES * 60; const deadline = Date.now() + SLACK_WAIT_MINUTES * 60_000; for (;;) { @@ -401,14 +403,16 @@ async function findVercelFailurePost() { }); const post = messages.find(message => { const text = slackMessageText(message); - return text.includes('failed to deploy') && text.includes(shortSha); + return ( + text.includes('failed to deploy') && text.includes(deploymentId) + ); }); if (post) { return post; } if (Date.now() >= deadline) { console.log( - `No Vercel "failed to deploy" post for ${shortSha} in the last ${SLACK_HISTORY_MINUTES} minutes; giving up. Messages seen:` + `No Vercel "failed to deploy" post for ${DEPLOYMENT_ID} in the last ${SLACK_HISTORY_MINUTES} minutes; giving up. Messages seen:` ); for (const message of messages) { console.log( @@ -418,7 +422,7 @@ async function findVercelFailurePost() { 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) From 8426ba1df967a2af3d065fd9223c94a294742253 Mon Sep 17 00:00:00 2001 From: Marc LeBlanc <7050295+marcleblanc2@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:37:49 -0600 Subject: [PATCH 3/4] ci: Look back a week for the Vercel Slack post and page through history The exact deployment ID match makes a wide window safe, and a re-run by hand can come long after the post. Print the newest 20 messages seen when giving up, to make the next miss easy to diagnose. --- dev/report-vercel-build.mjs | 55 ++++++++++++++++++++++++------------- 1 file changed, 36 insertions(+), 19 deletions(-) diff --git a/dev/report-vercel-build.mjs b/dev/report-vercel-build.mjs index a4766cdc9..af8e36e2f 100644 --- a/dev/report-vercel-build.mjs +++ b/dev/report-vercel-build.mjs @@ -24,9 +24,10 @@ * instead, and nothing is deleted. * * 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 + * 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. 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. * @@ -43,7 +44,9 @@ 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; @@ -393,28 +396,42 @@ function slackMessageText(message) { // runs; keep looking for a while before giving up. async function findVercelFailurePost() { const deploymentId = DEPLOYMENT_ID.replace(/^dpl_/, ''); - const oldest = Date.now() / 1000 - SLACK_HISTORY_MINUTES * 60; + 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 => { - const text = slackMessageText(message); - return ( - text.includes('failed to deploy') && text.includes(deploymentId) + // 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}) + } ); - }); - if (post) { - return post; + 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 ${DEPLOYMENT_ID} in the last ${SLACK_HISTORY_MINUTES} minutes; giving up. Messages seen:` + `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 messages) { + 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))}` ); From 896094419cc62edff36aaf72223157286043c671 Mon Sep 17 00:00:00 2001 From: Marc LeBlanc <7050295+marcleblanc2@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:50:09 -0600 Subject: [PATCH 4/4] ci: Keep the Vercel build log in Slack; the PR comment only links to it The repository is public, so nothing a build prints should land in a PR comment or a workflow artifact. The log is attached as .txt to the Vercel Slack post, so Slack renders it inline, and the PR comment links to that reply (or to the channel when the upload did not happen). --- .github/workflows/vercel-build-report.yml | 39 ++-- AGENTS.md | 2 +- dev/report-vercel-build.mjs | 208 ++++++++-------------- 3 files changed, 93 insertions(+), 156 deletions(-) diff --git a/.github/workflows/vercel-build-report.yml b/.github/workflows/vercel-build-report.yml index e64e343b1..624bec91f 100644 --- a/.github/workflows/vercel-build-report.yml +++ b/.github/workflows/vercel-build-report.yml @@ -1,12 +1,12 @@ 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 @@ -36,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 }} @@ -71,28 +69,19 @@ jobs: 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 == 'failed' - # The PR comment is the record; a Slack problem must not fail it + 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 af8e36e2f..caddf06d7 100644 --- a/dev/report-vercel-build.mjs +++ b/dev/report-vercel-build.mjs @@ -2,34 +2,33 @@ /** * 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 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. 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. + * 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 (failed or success), COMMIT_SHA, * GH_TOKEN and GITHUB_REPOSITORY. @@ -41,9 +40,6 @@ 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; // 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; @@ -55,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}); @@ -177,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]'], @@ -202,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() { @@ -225,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 === 'failed') { - 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` @@ -448,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', @@ -485,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() { @@ -499,14 +448,17 @@ async function 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); + } } } @@ -525,22 +477,18 @@ async function main() { 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); } }