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) {