diff --git a/.github/workflows/vercel-build-log.yml b/.github/workflows/vercel-build-log.yml index 9dcdcfdfd..49d66b2a4 100644 --- a/.github/workflows/vercel-build-log.yml +++ b/.github/workflows/vercel-build-log.yml @@ -5,7 +5,9 @@ name: Vercel build log # each deployment (SLACK_BOT_TOKEN secret and SLACK_CHANNEL_ID variable; see # dev/vercel-build-log-slack-app-manifest.json), with a link to the PR: the # open PR at the commit for a preview deployment, or the merged PR that -# introduced it for a production one. When a PR's build fails, the PR also +# introduced it for a production one. A build that passed also lists the +# pages the PR changed, from the preview-links workflow's PR comment, on the +# preview or in production. When a PR's build fails, the PR also # gets a comment linking to the log; 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 diff --git a/AGENTS.md b/AGENTS.md index b4021bca9..5ff1a5567 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -135,6 +135,9 @@ next run. Slack app's "failed to deploy" post in the `SLACK_CHANNEL_ID` channel and comments a link to it on the PR (see `dev/vercel-build-log.mjs`). The log itself never goes on the PR, since the repository is public. + A build that passed also lists the pages the PR changed in the Slack + reply, from the `preview-links.yml` comment: on the preview for an open + PR, in production for a merged one. - 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. diff --git a/dev/vercel-build-log.mjs b/dev/vercel-build-log.mjs index 9813ca0f3..5dd39b4e0 100644 --- a/dev/vercel-build-log.mjs +++ b/dev/vercel-build-log.mjs @@ -24,7 +24,10 @@ * such as on an invalid vercel.json, has an error but no log lines), and * redacted. * 3. The log is uploaded into the thread of the Vercel Slack app's post for - * the deployment in SLACK_CHANNEL_ID, with a comment linking the PR. The + * the deployment in SLACK_CHANNEL_ID, with a comment linking the PR and, + * when the build passed, the docs pages the PR changed, taken from the + * preview-links workflow's PR comment: on the preview for an open PR, in + * production for a merged one. The * post is looked for a week back (so a re-run by hand still finds it) and * for up to 5 minutes (the app can post after this runs). Skipped when * SLACK_BOT_TOKEN or SLACK_CHANNEL_ID is unset; a Slack error is a @@ -48,6 +51,10 @@ const DRY_RUN = process.argv.includes('--dry-run'); const SLACK_HISTORY_DAYS = 7; const SLACK_WAIT_MINUTES = 5; const SLACK_POLL_SECONDS = 15; +// The preview-links workflow comments on the PR when the same deployment +// event arrives, so it can still be running when this looks for its comment +const PREVIEW_LINKS_WAIT_MINUTES = 1; +const PREVIEW_LINKS_POLL_SECONDS = 10; const API_URL = process.env.GITHUB_API_URL ?? 'https://api.github.com'; const REPOSITORY = process.env.GITHUB_REPOSITORY; @@ -57,6 +64,14 @@ const {DEPLOYMENT_ID, DEPLOYMENT_STATE, COMMIT_SHA, SLACK_CHANNEL_ID} = // Comments from before the log moved to Slack carry an artifact ID here const MARKER = ''; const MARKER_PATTERN = /^/; +// The comment .github/workflows/preview-links.yml leaves on a PR +const PREVIEW_LINKS_MARKER = ''; +// Where a merged PR's pages are (basePath in next.config.js) +const PRODUCTION_SITE = 'https://sourcegraph.com/docs'; + +function sleep(seconds) { + return new Promise(resolve => setTimeout(resolve, seconds * 1000)); +} // ---------------------------------------------------------------- GitHub @@ -313,9 +328,7 @@ async function findVercelPost() { console.log( `No Vercel post for ${DEPLOYMENT_ID} yet; checking again in ${SLACK_POLL_SECONDS}s` ); - await new Promise(resolve => - setTimeout(resolve, SLACK_POLL_SECONDS * 1000) - ); + await sleep(SLACK_POLL_SECONDS); } } @@ -339,18 +352,85 @@ function pullRequestLinks(pulls) { .join(', '); } +// The pages the PR changed, from the preview-links workflow's PR comment, as +// Slack links: its `- [/path](preview url)` lines become `• `, +// pointing at the preview for an open PR and at production for a merged one, +// where the pages now are. The comment's `- …and N more` line is kept. +function changedPageLines(pull, commentBody) { + const production = Boolean(pull.merged_at); + const lines = []; + for (const line of commentBody.split('\n')) { + const link = line.match(/^- \[([^\]]+)\]\((https:\/\/[^)\s]+)\)$/); + if (link) { + const [, path, previewUrl] = link; + const url = production ? `${PRODUCTION_SITE}${path}` : previewUrl; + lines.push(`• <${url}|${escapeSlackText(path)}>`); + } else if (line.startsWith('- ')) { + lines.push(line.replace('- ', '• ')); + } + } + if (lines.length === 0) { + return []; + } + const where = production ? 'in production' : 'on the preview'; + return [`Pages changed in this PR, ${where}:`, ...lines]; +} + +// The preview-links workflow is started by the same deployment event as this +// one, so an open PR gets a minute to be commented on. A merged PR's comment +// is either there already or never coming. +async function findChangedPageLines(pull) { + const wait = pull.state === 'open' ? PREVIEW_LINKS_WAIT_MINUTES : 0; + const deadline = Date.now() + wait * 60_000; + for (;;) { + const comments = await githubList( + `/repos/${REPOSITORY}/issues/${pull.number}/comments` + ); + const comment = comments.find(comment => + comment.body.includes(PREVIEW_LINKS_MARKER) + ); + if (comment) { + return changedPageLines(pull, comment.body); + } + if (Date.now() >= deadline) { + console.log( + `PR #${pull.number} has no preview links comment; not listing its pages` + ); + return []; + } + console.log( + `PR #${pull.number} has no preview links comment yet; checking again in ${PREVIEW_LINKS_POLL_SECONDS}s` + ); + await sleep(PREVIEW_LINKS_POLL_SECONDS); + } +} + +// Links to the changed pages for a deployment that built; a failed build +// changed nothing anyone can open +async function changedPageLinks(pulls) { + if (DEPLOYMENT_STATE !== 'success') { + return []; + } + const lines = []; + for (const pull of pulls) { + lines.push(...(await findChangedPageLines(pull))); + } + return lines; +} + // 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. The PR link -// rides on the file's comment, which is the one message the bot can post -// without chat:write. Returns the permalink of the reply carrying the file, -// for the PR comment. -async function uploadLogToThread(post, pulls, {log, error}) { +// and page links ride on the file's comment, which is the one message the +// bot can post without chat:write. Returns the permalink of the reply +// carrying the file, for the PR comment. +async function uploadLogToThread(post, pulls, {log, error}, changedPages) { const shortSha = COMMIT_SHA.slice(0, 7); // .txt, so Slack shows it inline instead of offering a download const filename = `vercel-build-${shortSha}.txt`; const initialComment = [ error && `> ${error}`, - `Build log for PR ${pullRequestLinks(pulls)}` + `Build log for PR ${pullRequestLinks(pulls)}`, + ...changedPages ] .filter(Boolean) .join('\n'); @@ -420,7 +500,18 @@ async function attachLogInSlack(pulls, buildLog) { ); } const post = await findVercelPost(); - const permalink = post && (await uploadLogToThread(post, pulls, buildLog)); + if (!post) { + return {channel}; + } + // After the post is found, so the preview-links workflow has had that + // long to comment too + const changedPages = await changedPageLinks(pulls); + const permalink = await uploadLogToThread( + post, + pulls, + buildLog, + changedPages + ); return {channel, permalink}; }