Skip to content

Commit b60e025

Browse files
Qbandevclaude
andauthored
feat(ci): add automatic build comments to PRs (#22993)
## **Description** Adds automatic RC build comment functionality to post comments on Release PRs with build links when RC builds complete. **Per INFRA-3016 requirements:** - **iOS**: TestFlight link with "Go to TestFlight and download build xxxx" - **Android**: Bitrise public install link - **Auto-minimize** older RC build comments to keep PR timeline clean ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: INFRA-3016 ## **Manual testing steps** ```gherkin Feature: Automatic RC Build Comments Scenario: RC build posts comment to Release PR Given a release branch `release/x.y.z` exists with a PR that has `auto-rc-builds` label When a commit is pushed to the release branch Then RC build triggers on Bitrise And a comment is posted to the PR with iOS TestFlight link and Android install link Scenario: Older RC build comments are minimized Given a Release PR has existing RC build comments When a new RC build completes Then a new comment is posted with the latest build links And all previous RC build comments are minimized (collapsed) ``` ## **Screenshots/Recordings** ### **Before** N/A - New feature ### **After** **RC Build Comment Format:** | Platform | Link | Version | | :--- | :--- | :--- | | **iOS** | [TestFlight](https://testflight.apple.com/join/hBrjtFuA) | Go to TestFlight and download build `1234` | | **Android** | [Install](https://bitrise.io/...) | RC 7.62.0 (1234) | **Test Results:** ``` === Searching for existing RC build comments on PR #22993 === Found 40 total comments on PR #22993 Found 3 existing RC build comment(s) Creating new comment with RC build URLs... ✓ Successfully created new comment with RC build URLs === Minimizing 3 older RC build comment(s) === ✓ Minimized comment 3773140874 ✓ Minimized comment 3773142511 ✓ Minimized comment 3773845357 ✓ Finished processing older RC build comments ``` ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. ## **Files Changed** | File | Lines | Description | |------|-------|-------------| | `.github/scripts/rc-builds.sh` | +7 | GITHUB_OUTPUT exports | | `.github/workflows/build-rc-auto.yml` | +35 | Outputs + post-rc-build-comment job | | `scripts/post-rc-build-comment.mjs` | +195 | Posts comment, minimizes old ones | | `.eslintrc.js` | +17 | ESLint config for .mjs files | | **Total** | **+254** | | --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent c9bc341 commit b60e025

4 files changed

Lines changed: 254 additions & 0 deletions

File tree

.eslintrc.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,23 @@ module.exports = {
7676
],
7777
},
7878
},
79+
{
80+
files: ['scripts/**/*.mjs'],
81+
parser: '@babel/eslint-parser',
82+
parserOptions: {
83+
requireConfigFile: false,
84+
babelOptions: {
85+
presets: ['@babel/preset-env'],
86+
},
87+
ecmaVersion: 2022,
88+
sourceType: 'module',
89+
},
90+
rules: {
91+
'no-console': 'off',
92+
'import/no-commonjs': 'off',
93+
'import/no-nodejs-modules': 'off',
94+
},
95+
},
7996
{
8097
files: ['scripts/**/*.js', 'e2e/tools/**/*.{js,ts}', 'app.config.js'],
8198
rules: {

.github/scripts/rc-builds.sh

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,3 +124,10 @@ echo "Android build ID: $ANDROID_WORKFLOW_ID"
124124
echo "iOS Build ID: $IOS_WORKFLOW_ID"
125125
echo "Android public link: $ANDROID_PUBLIC_URL"
126126
echo "Build number: $BUILD_NUMBER"
127+
128+
# Export outputs to GITHUB_OUTPUT for use in subsequent jobs
129+
if [[ -n "${GITHUB_OUTPUT:-}" ]]; then
130+
echo "android-public-url=$ANDROID_PUBLIC_URL" >> "$GITHUB_OUTPUT"
131+
echo "bitrise-pipeline-url=https://app.bitrise.io/app/$BITRISE_APP_ID/pipelines/$BUILD_SLUG" >> "$GITHUB_OUTPUT"
132+
echo "build-number=$BUILD_NUMBER" >> "$GITHUB_OUTPUT"
133+
fi

.github/workflows/build-rc-auto.yml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ jobs:
2121
semver: ${{ steps.extract-version.outputs.semver }}
2222
has-label: ${{ steps.check-label.outputs.has-label }}
2323
branch-name: ${{ steps.extract-version.outputs.branch-name }}
24+
pr-number: ${{ steps.check-label.outputs.pr-number }}
2425
permissions:
2526
pull-requests: read
2627
steps:
@@ -63,6 +64,7 @@ jobs:
6364
fi
6465
6566
echo "Found PR #$PR_NUMBER"
67+
echo "pr-number=$PR_NUMBER" >> "$GITHUB_OUTPUT"
6668
6769
# Check if PR has the auto-rc-builds label
6870
LABELS=$(gh pr view "$PR_NUMBER" --json labels --jq '.labels[].name' || echo "")
@@ -94,13 +96,18 @@ jobs:
9496
- validate-and-check-label
9597
- bump-version
9698
if: needs.validate-and-check-label.outputs.has-label == 'true'
99+
outputs:
100+
android-public-url: ${{ steps.rc-build.outputs.android-public-url }}
101+
bitrise-pipeline-url: ${{ steps.rc-build.outputs.bitrise-pipeline-url }}
102+
build-number: ${{ steps.rc-build.outputs.build-number }}
97103
steps:
98104
- name: Checkout repository
99105
uses: actions/checkout@v3
100106
with:
101107
fetch-depth: 0
102108
ref: ${{ github.ref }}
103109
- name: Trigger RC Build
110+
id: rc-build
104111
env:
105112
SEMVER: ${{ needs.validate-and-check-label.outputs.semver }}
106113
GH_REF_NAME: ${{ github.ref_name }}
@@ -110,3 +117,31 @@ jobs:
110117
BITRISE_BUILD_TRIGGER_TOKEN: ${{ secrets.BITRISE_BUILD_TRIGGER_TOKEN }}
111118
BITRISE_API_TOKEN: ${{ secrets.BITRISE_API_TOKEN }}
112119
run: ./.github/scripts/rc-builds.sh
120+
121+
post-rc-build-comment:
122+
name: Post RC Build Comment
123+
runs-on: ubuntu-latest
124+
needs:
125+
- validate-and-check-label
126+
- trigger-rc-build
127+
if: always() && needs.trigger-rc-build.result == 'success' && needs.validate-and-check-label.outputs.pr-number != ''
128+
permissions:
129+
pull-requests: write
130+
steps:
131+
- uses: actions/checkout@v4
132+
- uses: actions/setup-node@v4
133+
with:
134+
node-version-file: '.nvmrc'
135+
cache: yarn
136+
- name: Install dependencies
137+
run: yarn install --immutable
138+
- name: Post RC Build Comment
139+
run: node scripts/post-rc-build-comment.mjs
140+
env:
141+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
142+
GITHUB_REPOSITORY: ${{ github.repository }}
143+
PR_NUMBER: ${{ needs.validate-and-check-label.outputs.pr-number }}
144+
SEMVER: ${{ needs.validate-and-check-label.outputs.semver }}
145+
BUILD_NUMBER: ${{ needs.trigger-rc-build.outputs.build-number }}
146+
ANDROID_PUBLIC_URL: ${{ needs.trigger-rc-build.outputs.android-public-url }}
147+
BITRISE_PIPELINE_URL: ${{ needs.trigger-rc-build.outputs.bitrise-pipeline-url }}

scripts/post-rc-build-comment.mjs

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
// eslint-disable-next-line import/no-extraneous-dependencies
2+
import { Octokit } from '@octokit/rest';
3+
4+
const RC_BUILD_COMMENT_MARKER = '<!-- metamask-bot-rc-build-announce -->';
5+
const TESTFLIGHT_URL = 'https://testflight.apple.com/join/hBrjtFuA';
6+
7+
/**
8+
* Checks if a URL value is valid (not empty, null, placeholder, and proper URL format).
9+
* @param {string | undefined} url - The URL to check
10+
* @returns {boolean} Whether the URL is valid
11+
*/
12+
function isValidUrl(url) {
13+
if (!url || typeof url !== 'string') {
14+
return false;
15+
}
16+
const trimmed = url.trim().toLowerCase();
17+
if (trimmed === '' || trimmed === 'n/a' || trimmed === 'null' || trimmed === 'undefined') {
18+
return false;
19+
}
20+
try {
21+
const parsed = new URL(url);
22+
return parsed.protocol === 'https:' || parsed.protocol === 'http:';
23+
} catch {
24+
return false;
25+
}
26+
}
27+
28+
/**
29+
* Minimizes (hides) a comment using GitHub GraphQL API.
30+
* @param {Octokit} octokit - Octokit instance
31+
* @param {string} nodeId - The GraphQL node ID of the comment
32+
* @returns {Promise<boolean>} Whether the operation was successful
33+
*/
34+
async function minimizeComment(octokit, nodeId) {
35+
try {
36+
await octokit.graphql(
37+
`
38+
mutation MinimizeComment($id: ID!, $classifier: ReportedContentClassifiers!) {
39+
minimizeComment(input: { subjectId: $id, classifier: $classifier }) {
40+
minimizedComment {
41+
isMinimized
42+
minimizedReason
43+
}
44+
}
45+
}
46+
`,
47+
{
48+
id: nodeId,
49+
classifier: 'OUTDATED',
50+
},
51+
);
52+
return true;
53+
} catch (error) {
54+
console.error(`Failed to minimize comment ${nodeId}:`, error.message);
55+
return false;
56+
}
57+
}
58+
59+
/**
60+
* Posts a new PR comment with RC build links and minimizes older RC build comments.
61+
*
62+
* iOS uses TestFlight link with build number reference.
63+
* Android uses Bitrise public install page URL.
64+
*
65+
* Requires environment variables: GITHUB_TOKEN, GITHUB_REPOSITORY, PR_NUMBER, SEMVER,
66+
* BUILD_NUMBER, ANDROID_PUBLIC_URL, BITRISE_PIPELINE_URL
67+
*/
68+
async function start() {
69+
const {
70+
GITHUB_TOKEN,
71+
GITHUB_REPOSITORY,
72+
PR_NUMBER,
73+
SEMVER,
74+
BUILD_NUMBER,
75+
ANDROID_PUBLIC_URL,
76+
BITRISE_PIPELINE_URL,
77+
} = process.env;
78+
79+
// Validate required environment variables
80+
if (!GITHUB_TOKEN?.trim() || !GITHUB_REPOSITORY?.trim() || !PR_NUMBER?.trim()) {
81+
console.error(
82+
'Missing or empty required environment variables: GITHUB_TOKEN, GITHUB_REPOSITORY, PR_NUMBER',
83+
);
84+
process.exit(1);
85+
}
86+
87+
const [owner, repo] = GITHUB_REPOSITORY.split('/');
88+
if (!owner || !repo) {
89+
console.error(`GITHUB_REPOSITORY must be in format owner/repo, got: ${GITHUB_REPOSITORY}`);
90+
process.exit(1);
91+
}
92+
93+
const prNumber = parseInt(PR_NUMBER, 10);
94+
if (isNaN(prNumber) || prNumber <= 0) {
95+
console.error(`PR_NUMBER must be a positive integer, got: ${PR_NUMBER}`);
96+
process.exit(1);
97+
}
98+
99+
const octokit = new Octokit({ auth: GITHUB_TOKEN });
100+
101+
// Build the comment body
102+
const rows = [];
103+
const version = SEMVER || 'Unknown';
104+
const buildNum = BUILD_NUMBER || 'Unknown';
105+
106+
// iOS always uses TestFlight link with build number reference
107+
rows.push(`| **iOS** | [TestFlight](${TESTFLIGHT_URL}) | Go to TestFlight and download build \`${buildNum}\` |`);
108+
109+
// Add Android row if public URL is available
110+
if (isValidUrl(ANDROID_PUBLIC_URL)) {
111+
rows.push(`| **Android** | [Install](${ANDROID_PUBLIC_URL}) | RC ${version} (${buildNum}) |`);
112+
} else {
113+
console.error('ERROR: No Android public install URL available.');
114+
console.error(` ANDROID_PUBLIC_URL: ${ANDROID_PUBLIC_URL || '(not set)'}`);
115+
console.error('This may indicate a Bitrise configuration issue - artifact may not have public page enabled.');
116+
process.exit(1);
117+
}
118+
119+
const pipelineLink = isValidUrl(BITRISE_PIPELINE_URL)
120+
? `[View Pipeline](${BITRISE_PIPELINE_URL})`
121+
: 'Not available';
122+
123+
const commentBody = `${RC_BUILD_COMMENT_MARKER}
124+
### :rocket: RC Builds Ready for Testing
125+
126+
| Platform | Link | Version |
127+
| :--- | :--- | :--- |
128+
${rows.join('\n')}
129+
130+
<details>
131+
<summary>More Info</summary>
132+
133+
* **Version**: \`${version}\`
134+
* **Build Number**: \`${buildNum}\`
135+
* **Bitrise Pipeline**: ${pipelineLink}
136+
</details>
137+
`;
138+
139+
// Post new comment and minimize old ones
140+
try {
141+
console.log(`\n=== Searching for existing RC build comments on PR #${prNumber} ===`);
142+
const comments = await octokit.paginate(octokit.rest.issues.listComments, {
143+
owner,
144+
repo,
145+
issue_number: prNumber,
146+
});
147+
148+
console.log(`Found ${comments.length} total comments on PR #${prNumber}`);
149+
150+
// Find all existing RC build bot comments
151+
const existingBotComments = comments.filter(
152+
(comment) => comment.body && comment.body.includes(RC_BUILD_COMMENT_MARKER),
153+
);
154+
155+
console.log(`Found ${existingBotComments.length} existing RC build comment(s)`);
156+
157+
// Create new comment first (so it appears at the bottom)
158+
console.log('Creating new comment with RC build URLs...');
159+
await octokit.rest.issues.createComment({
160+
owner,
161+
repo,
162+
issue_number: prNumber,
163+
body: commentBody,
164+
});
165+
console.log(`✓ Successfully created new comment with RC build URLs`);
166+
167+
// Minimize all previous RC build comments
168+
if (existingBotComments.length > 0) {
169+
console.log(`\n=== Minimizing ${existingBotComments.length} older RC build comment(s) ===`);
170+
for (const comment of existingBotComments) {
171+
if (comment.node_id) {
172+
console.log(`Minimizing comment ID: ${comment.id} (node_id: ${comment.node_id})`);
173+
const success = await minimizeComment(octokit, comment.node_id);
174+
if (success) {
175+
console.log(`✓ Minimized comment ${comment.id}`);
176+
}
177+
} else {
178+
console.warn(`Comment ${comment.id} does not have a node_id, skipping minimization`);
179+
}
180+
}
181+
console.log(`✓ Finished processing older RC build comments`);
182+
}
183+
} catch (error) {
184+
console.error('Error posting/minimizing comments:', error);
185+
if (error.status === 403) {
186+
console.error('Permission denied. Ensure the GITHUB_TOKEN has "pull-requests: write" permission.');
187+
}
188+
process.exit(1);
189+
}
190+
}
191+
192+
start().catch((error) => {
193+
console.error(error);
194+
process.exit(1);
195+
});

0 commit comments

Comments
 (0)