diff --git a/.github/workflows/build-rc-auto.yml b/.github/workflows/build-rc-auto.yml index 95a6dba90f1..bab5bd80996 100644 --- a/.github/workflows/build-rc-auto.yml +++ b/.github/workflows/build-rc-auto.yml @@ -144,7 +144,7 @@ jobs: - uses: actions/checkout@v4 with: ref: ${{ needs.validate-and-find-pr.outputs.branch-name }} - fetch-depth: 50 + fetch-depth: 0 - uses: actions/setup-node@v4 with: node-version-file: '.nvmrc' @@ -199,4 +199,5 @@ jobs: semver: ${{ needs.trigger-ios-rc-build.outputs.semantic_version }} ios_build_number: ${{ needs.trigger-ios-rc-build.outputs.ios_version_code }} android_build_number: ${{ needs.trigger-android-rc-build.outputs.android_version_code }} + pr_number: ${{ needs.validate-and-find-pr.outputs.pr-number }} secrets: inherit diff --git a/.github/workflows/prod-build-env-notify.yml b/.github/workflows/prod-build-env-notify.yml index c3e631258bb..7d2bf8a8cde 100644 --- a/.github/workflows/prod-build-env-notify.yml +++ b/.github/workflows/prod-build-env-notify.yml @@ -64,8 +64,6 @@ jobs: production) REMOTE_FF_ENV="prod" ;; rc) REMOTE_FF_ENV="rc" ;; beta) REMOTE_FF_ENV="beta" ;; - test|e2e) REMOTE_FF_ENV="test" ;; - exp) REMOTE_FF_ENV="exp" ;; *) REMOTE_FF_ENV="dev" ;; esac diff --git a/.github/workflows/slack-rc-notification.yml b/.github/workflows/slack-rc-notification.yml index f5b9f04a91c..4183005e56d 100644 --- a/.github/workflows/slack-rc-notification.yml +++ b/.github/workflows/slack-rc-notification.yml @@ -31,6 +31,11 @@ on: required: false type: string default: '' + pr_number: + description: 'PR number for linking to cherry-picks section in release PR comment' + required: false + type: string + default: '' jobs: slack-notification: @@ -65,3 +70,4 @@ jobs: BUILD_PIPELINE_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} ANDROID_PUBLIC_URL: ${{ secrets.ANDROID_PUBLIC_BUCKET_URL }} SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + PR_NUMBER: ${{ inputs.pr_number }} diff --git a/scripts/build-announce/cherry-picks-section.ts b/scripts/build-announce/cherry-picks-section.ts new file mode 100644 index 00000000000..3295b1a6bbe --- /dev/null +++ b/scripts/build-announce/cherry-picks-section.ts @@ -0,0 +1,105 @@ +/** + * Extracts commits from release branch and builds collapsible markdown section. + */ + +import { execFileSync } from 'child_process'; + +const REPO_URL = process.env.GITHUB_REPOSITORY + ? `https://github.com/${process.env.GITHUB_REPOSITORY}` + : 'https://github.com/MetaMask/metamask-mobile'; + +const SKIP_CI_BUMP_VERSION_SUBJECT = /^\[skip ci\] Bump version number to/; + +function getMergeBase(headRef: string, baseRef: string): string { + const out = execFileSync('git', ['merge-base', headRef, baseRef], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + const sha = out.trim(); + if (!sha) { + throw new Error(`git merge-base returned empty for ${headRef} and ${baseRef}`); + } + return sha; +} + +function getAncestryPathCommits( + mergeBaseHash: string, + headRef: string, +): { hash: string; subject: string }[] { + const out = execFileSync( + 'git', + ['log', '--ancestry-path', `${mergeBaseHash}..${headRef}`, '--pretty=format:%h\t%s'], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }, + ); + + if (!out.trim()) return []; + + return out + .trim() + .split('\n') + .filter((line) => { + const tab = line.indexOf('\t'); + const subject = tab >= 0 ? line.slice(tab + 1) : line; + return !SKIP_CI_BUMP_VERSION_SUBJECT.test(subject.trim()); + }) + .map((line) => { + const tab = line.indexOf('\t'); + return { + hash: tab >= 0 ? line.slice(0, tab) : '', + subject: tab >= 0 ? line.slice(tab + 1) : line, + }; + }); +} + +function formatSubjectWithPrLinks(subject: string): string { + return subject + .replace(/\(#(\d+)\)/g, (_, n) => `([#${n}](${REPO_URL}/pull/${n}))`) + .replace(/(^|\s)#(\d+)\b/g, (_, lead, n) => `${lead}[#${n}](${REPO_URL}/pull/${n})`); +} + +export function extractCherryPicks(): { hash: string; subject: string }[] { + const baseRef = process.env.MERGE_BASE_REF ?? 'origin/main'; + const headRef = (process.env.HEAD_REF ?? '').trim() || 'HEAD'; + + const mergeBase = getMergeBase(headRef, baseRef); + const commits = getAncestryPathCommits(mergeBase, headRef); + console.log(`[cherry-picks] Found ${commits.length} commit(s)`); + return commits; +} + +export function buildCherryPicksSection( + commits: { hash: string; subject: string }[], +): string { + if (commits.length === 0) return ''; + + const lines: string[] = [ + '', + '### :cherries: What\'s in this RC\n', + '
', + `${commits.length} commit(s) in this release\n`, + '| Commit | Description |', + '| :--- | :--- |', + ]; + + for (const commit of commits) { + const linkedSubject = formatSubjectWithPrLinks(commit.subject); + const commitLink = `[\`${commit.hash}\`](${REPO_URL}/commit/${commit.hash})`; + lines.push(`| ${commitLink} | ${linkedSubject} |`); + } + + lines.push('\n
\n'); + return lines.join('\n'); +} + +export function buildCherryPicksFailureSection(error?: string): string { + let section = ` +### :cherries: What's in this RC + +_Could not extract commit list._`; + + if (error) { + section += `\n\n
\nDetails\n\n${error}\n\n
`; + } + + return section + '\n'; +} diff --git a/scripts/build-announce/env-validation-section.ts b/scripts/build-announce/env-validation-section.ts index 4309868d5bd..1eb71ae2e18 100644 --- a/scripts/build-announce/env-validation-section.ts +++ b/scripts/build-announce/env-validation-section.ts @@ -18,12 +18,6 @@ function getRemoteFFEnv(env: string | undefined): string { return 'rc'; case 'beta': return 'beta'; - case 'test': - case 'e2e': - return 'test'; - case 'exp': - return 'exp'; - case 'dev': default: return 'dev'; } diff --git a/scripts/build-announce/index.ts b/scripts/build-announce/index.ts index ec242736aba..c08b794f69b 100644 --- a/scripts/build-announce/index.ts +++ b/scripts/build-announce/index.ts @@ -22,6 +22,11 @@ import { buildEnvValidationSection, buildEnvValidationFailureSection, } from './env-validation-section'; +import { + extractCherryPicks, + buildCherryPicksSection, + buildCherryPicksFailureSection, +} from './cherry-picks-section'; import { validateEnv } from './validate-env'; import type { BuildInfo, TestPlanResult, EnvValidationResult } from './types'; @@ -151,6 +156,10 @@ function buildCommentBody( iosResult?: EnvValidationResult; error?: string; }, + cherryPicks: { + commits: { hash: string; subject: string }[]; + error?: string; + }, testPlanError?: string, ): string { let body = `${RC_BUILD_COMMENT_MARKER} @@ -171,6 +180,15 @@ ${buildMoreInfoSection(buildInfo)} body += buildEnvValidationFailureSection(envValidation.error); } + // Add cherry-picks section + if (cherryPicks.commits.length > 0) { + body += `---\n\n`; + body += buildCherryPicksSection(cherryPicks.commits); + } else if (cherryPicks.error) { + body += `---\n\n`; + body += buildCherryPicksFailureSection(cherryPicks.error); + } + // Add test plan section if (testPlan) { body += `---\n\n`; @@ -261,8 +279,22 @@ async function main(): Promise { console.log(' - No build-env artifacts found'); } + // Extract cherry-picks from git history + console.log('\n=== Cherry-picks ===\n'); + const cherryPicks: { commits: { hash: string; subject: string }[]; error?: string } = { + commits: [], + }; + + try { + cherryPicks.commits = extractCherryPicks(); + console.log(` - Found ${cherryPicks.commits.length} commit(s)`); + } catch (error) { + cherryPicks.error = error instanceof Error ? error.message : String(error); + console.error(` - Error: ${cherryPicks.error}`); + } + // Build the comment body - const commentBody = buildCommentBody(buildInfo, testPlan, envValidation, testPlanError); + const commentBody = buildCommentBody(buildInfo, testPlan, envValidation, cherryPicks, testPlanError); // Post comment and minimize old ones console.log(`\n=== Posting Comment to PR #${prNumber} ===\n`); diff --git a/scripts/slack-rc-notification.mjs b/scripts/slack-rc-notification.mjs index bc60b3df876..1a8e28d3cec 100644 --- a/scripts/slack-rc-notification.mjs +++ b/scripts/slack-rc-notification.mjs @@ -1,189 +1,19 @@ /** * Slack RC Build Notification Script * - * Posts a Slack message after an RC build. The *What's in this RC* block lists - * commits from **git history** (merge-base + ancestry path). + * Posts a Slack message after an RC build with download links + * and a link to the cherry-picks section in the PR comment. * - * Algorithm: - * - Run `git merge-base ` (default **headRef**: `HEAD`, override with - * `HEAD_REF`; default **baseRef**: `origin/main`, override with `MERGE_BASE_REF`) to get - * the fork point as a **merge-base commit hash**. - * - List commits on the fork-to-tip path only: - * `git log --ancestry-path ..` (same full hash from `git merge-base`; output uses short hash + subject), with optional - * PR links from `#123` / `(#123)` in subjects. - * - Omit commits whose subject starts with `[skip ci] Bump version number to` (automation noise). - * - Cap the list (see `MAX_RC_COMMIT_LINES`) and append *…and N more* when needed. - * - * **Fail-open:** If merge-base fails, `git log` errors, or the ancestry path is empty, - * the notification is still sent: *What's in this RC* is omitted in favor of a short - * fallback with a link to release notes on GitHub when needed. The process exits 0; - * git problems are logged to stderr. This matches CI/local use where shallow clones or - * missing refs can make history unavailable. - * - * Required environment variables: - * - SEMVER: Semantic version (e.g. "7.40.0") - * - IOS_BUILD_NUMBER: iOS build number - * - ANDROID_BUILD_NUMBER: Android build number - * - SLACK_BOT_TOKEN: Slack Bot OAuth token for API calls - * - * Optional environment variables: - * - ANDROID_PUBLIC_URL: Public URL for Android APK download - * - IOS_PUBLIC_URL: Public URL for iOS build - * - BUILD_PIPELINE_URL: GitHub Actions pipeline URL (footer: pipeline + release notes link) - * - GITHUB_REPOSITORY: "owner/repo" (defaults to metamask-mobile) - * - MERGE_BASE_REF: Ref for merge-base (default: origin/main) - * - HEAD_REF: Tip ref for `git merge-base` and `git log` range (default: `HEAD`; e.g. a - * branch name or SHA to preview another tip without checking it out) - * - SLACK_RC_NOTIFICATION_DRY_RUN: Set to `1` or `true` to print the message JSON and - * exit without calling Slack (`SLACK_BOT_TOKEN` not required in this mode) + * Required env: SEMVER, SLACK_BOT_TOKEN + * Optional env: IOS_BUILD_NUMBER, ANDROID_BUILD_NUMBER, ANDROID_PUBLIC_URL, + * IOS_PUBLIC_URL, BUILD_PIPELINE_URL, PR_NUMBER, GITHUB_REPOSITORY, + * SLACK_RC_NOTIFICATION_DRY_RUN */ -import { execFileSync } from 'child_process'; - -// Configuration const REPO_URL = process.env.GITHUB_REPOSITORY ? `https://github.com/${process.env.GITHUB_REPOSITORY}` : 'https://github.com/MetaMask/metamask-mobile'; -const MAX_RC_COMMIT_LINES = 10; - -/** Commits whose subject starts with this (version bump automation) are omitted from the RC list. */ -const SKIP_CI_BUMP_VERSION_SUBJECT = /^\[skip ci\] Bump version number to/; - -/** - * Run `git merge-base` and return the merge-base **commit hash**, or `null` if git errors - * or output is empty (fail-open: caller skips the RC commit list). - * @param {string} headRef - * @param {string} baseRef - * @returns {string|null} Merge-base commit hash (hex string from `git merge-base`), or `null` on failure - */ -function getMergeBase(headRef, baseRef) { - try { - const out = execFileSync('git', ['merge-base', headRef, baseRef], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - }); - const sha = out.trim(); - return sha || null; - } catch (error) { - console.error( - `[slack-rc-notification] git merge-base ${headRef} ${baseRef} failed (RC commit list skipped): ${error.message}`, - ); - return null; - } -} - -/** - * @param {string} mergeBaseCommitHash - Commit hash returned by `git merge-base` (common ancestor of `headRef` and the base ref) - * @param {string} headRef - Tip ref (default in callers: `HEAD`, overridable via `HEAD_REF`) - * @returns {string[]} Raw `hash\\tsubject` lines from `git log --ancestry-path` from merge-base to `headRef`, - * excluding `[skip ci] Bump version number to …` subjects; empty array if no commits or if git fails (fail-open). - */ -function getAncestryPathLogLines(mergeBaseCommitHash, headRef) { - try { - const out = execFileSync( - 'git', - ['log', '--ancestry-path', `${mergeBaseCommitHash}..${headRef}`, '--pretty=format:%h\t%s'], - { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - }, - ); - if (!out.trim()) { - return []; - } - return out - .trim() - .split('\n') - .filter((line) => { - const tab = line.indexOf('\t'); - const subject = tab >= 0 ? line.slice(tab + 1) : line; - return !SKIP_CI_BUMP_VERSION_SUBJECT.test(subject.trim()); - }); - } catch (error) { - console.error( - `[slack-rc-notification] git log --ancestry-path failed (RC commit list skipped): ${error.message}`, - ); - return []; - } -} - -/** - * Link (#123) and standalone #123 in a commit subject to the repo PR URL (Slack mrkdwn). - * @param {string} subject - * @returns {string} - */ -function formatSubjectWithPrLinks(subject) { - let result = subject; - result = result.replace(/\(#(\d+)\)/g, (_, n) => `(<${REPO_URL}/pull/${n}|#${n}>)`); - result = result.replace(/(^|\s)#(\d+)\b/g, (_, lead, n) => `${lead}<${REPO_URL}/pull/${n}|#${n}>`); - return result; -} - -/** - * Format one `hash\\tsubject` log line for Slack (bullet, short hash, subject with PR links). - * @param {string} line - * @returns {string} - */ -function formatCommitLineForSlack(line) { - const tab = line.indexOf('\t'); - const hash = tab >= 0 ? line.slice(0, tab) : ''; - const subject = tab >= 0 ? line.slice(tab + 1) : line; - const linked = formatSubjectWithPrLinks(subject); - return `• \`${hash}\` ${linked}`; -} - -/** - * Build Slack mrkdwn for RC commits (capped) and optional "...and N more" line. - * @param {string[]} logLines - Full list of hash\\tsubject lines - * @param {number} maxEntries - * @returns {{ text: string, hasEntries: boolean }} - */ -function formatCommitsForSlack(logLines, maxEntries = MAX_RC_COMMIT_LINES) { - if (!logLines.length) { - return { text: '', hasEntries: false }; - } - - const slice = logLines.slice(0, maxEntries); - const remaining = logLines.length - slice.length; - const bullets = slice.map(formatCommitLineForSlack); - if (remaining > 0) { - bullets.push(`\n_...and ${remaining} more_`); - } - return { text: bullets.join('\n'), hasEntries: true }; -} - -/** - * Resolve merge-base with `MERGE_BASE_REF` (default `origin/main`) and collect commits - * via `git log --ancestry-path`. - * @returns {{ text: string, hasEntries: boolean }} Slack mrkdwn and whether to show the RC list - */ -function extractRcCommitsFromGit() { - const baseRef = process.env.MERGE_BASE_REF ?? 'origin/main'; - const headRef = (process.env.HEAD_REF ?? '').trim() || 'HEAD'; - console.log(`\n📖 Git history (merge-base ${headRef} with ${baseRef}, ancestry-path to ${headRef})...`); - - const mergeBase = getMergeBase(headRef, baseRef); - if (!mergeBase) { - console.warn( - ' Could not resolve merge-base; skipping “What’s in this RC” (Slack will show fallback + release notes link when available)', - ); - return { text: '', hasEntries: false }; - } - console.log(` merge-base: ${mergeBase}`); - - const logLines = getAncestryPathLogLines(mergeBase, headRef); - if (!logLines.length) { - console.warn( - ' No commits on ancestry path; skipping “What’s in this RC” (Slack will show fallback + release notes link when available)', - ); - return { text: '', hasEntries: false }; - } - - console.log(` Found ${logLines.length} commit(s) on ancestry path`); - return formatCommitsForSlack(logLines); -} - /** * Check if a URL is valid * @param {string|undefined} url - The URL to check @@ -217,8 +47,7 @@ function buildSlackMessage(options) { androidUrl, iosUrl, pipelineUrl, - rcCommitsText, - hasRcCommits, + prNumber, } = options; const blocks = [ @@ -269,8 +98,9 @@ function buildSlackMessage(options) { }, ]; - // Add RC commit list if we have entries - if (hasRcCommits && rcCommitsText) { + // Add link to cherry-picks section in PR comment + if (prNumber) { + const cherryPicksLink = `<${REPO_URL}/pull/${prNumber}#cherry-picks|View what's in this RC>`; blocks.push( { type: 'divider', @@ -279,20 +109,17 @@ function buildSlackMessage(options) { type: 'section', text: { type: 'mrkdwn', - text: `*📋 What's in this RC:*\n${rcCommitsText}`, + text: `*📋 What's in this RC:*\n${cherryPicksLink}`, }, }, ); } else { const releaseNotesMrkdwn = `<${REPO_URL}/tree/release/${version}|View release notes>`; - const fallbackMrkdwn = pipelineUrl - ? `_Could not list RC commits (empty ancestry path, merge-base failed, incomplete git history, or \`git log\` failed). For full notes see ${releaseNotesMrkdwn} — also linked in the footer below._` - : `_Could not list RC commits (empty ancestry path, merge-base failed, incomplete git history, or \`git log\` failed). For full notes see ${releaseNotesMrkdwn}._`; blocks.push({ type: 'section', text: { type: 'mrkdwn', - text: fallbackMrkdwn, + text: `_Cherry-picks list available in the release PR. ${releaseNotesMrkdwn}_`, }, }); } @@ -399,17 +226,19 @@ async function main() { const pipelineUrl = process.env.BUILD_PIPELINE_URL; const botToken = process.env.SLACK_BOT_TOKEN; + const prNumber = process.env.PR_NUMBER || ''; const expectedChannelName = getSlackChannel(version); console.log(`\n📣 Preparing Slack notification for RC v${version} (${buildNumber})`); + if (prNumber) { + console.log(`📍 Release PR: #${prNumber}`); + } if (isDryRun) { console.log('🧪 DRY RUN: will print payload JSON and not call Slack'); } else { console.log(`📍 Target channel: ${expectedChannelName}`); } - const { text: rcCommitsText, hasEntries: hasRcCommits } = extractRcCommitsFromGit(); - // Build and send the message console.log('\n📤 Posting to Slack...'); @@ -419,8 +248,7 @@ async function main() { androidUrl, iosUrl, pipelineUrl, - rcCommitsText, - hasRcCommits, + prNumber, }); if (isDryRun) {