Skip to content

Commit a44d68e

Browse files
cortinicometa-codesync[bot]
authored andcommitted
Split Android build from release publishing (#57714)
Summary: This should reduce the publishing time needed for `publish_react_native` by parallelizing Android & iOS build ## Changelog: [INTERNAL] - Pull Request resolved: #57714 Test Plan: N/A Reviewed By: cipolleschi Differential Revision: D113889729 Pulled By: cortinico fbshipit-source-id: aaa66a3cb9ce5c22bf15780503cf9a063c4b2725
1 parent e92816c commit a44d68e

8 files changed

Lines changed: 535 additions & 14 deletions

File tree

.github/actions/build-android/action.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,10 @@ runs:
6262
export HERMES_PREBUILT_FLAG="ORG_GRADLE_PROJECT_react.internal.useHermesNightly=true"
6363
TASKS="publishAllToMavenTempLocal publishAndroidToSonatype build"
6464
else
65-
# release: we want to build all archs (default)
65+
# release: build all archs and close the repository so that Sonatype can validate it.
66+
# Releasing to Maven Central happens only after the Apple builds succeed.
6667
export HERMES_PREBUILT_FLAG="ORG_GRADLE_PROJECT_react.internal.useHermesStable=true"
67-
TASKS="publishAllToMavenTempLocal publishAndroidToSonatype build"
68+
TASKS="publishAllToMavenTempLocal publishAndroidToSonatype closeSonatypeStagingRepository build"
6869
fi
6970
env "$HERMES_PREBUILT_FLAG" ./gradlew $TASKS -PenableWarningsAsErrors=true
7071
- name: Save Android ccache

.github/workflows/publish-npm.yml

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ on:
3030
permissions:
3131
contents: read
3232

33+
env:
34+
# Stable across partial reruns so later jobs can find the repository created by build_android.
35+
ORG_GRADLE_PROJECT_SONATYPE_REPOSITORY_DESCRIPTION: 'react-native:${{ github.ref_name }}:github-run-${{ github.run_id }}'
36+
3337
jobs:
3438
# ─── Determine what kind of publish this is ──────────────────────
3539
determine_mode:
@@ -86,11 +90,10 @@ jobs:
8690
use-hermes-prebuilt: ${{ needs.determine_mode.outputs.mode == 'nightly' }}
8791
version-type: ${{ needs.determine_mode.outputs.mode == 'nightly' && 'nightly' || '' }}
8892

89-
# ─── Android build (nightly only — releases handle this in the
90-
# build-npm-package action's Gradle step) ─────────────────────
93+
# ─── Android build (release + nightly) ───────────────────────────
9194
build_android:
9295
needs: [determine_mode]
93-
if: needs.determine_mode.outputs.mode == 'nightly'
96+
if: needs.determine_mode.outputs.mode == 'release' || needs.determine_mode.outputs.mode == 'nightly'
9497
runs-on: ubuntu-latest
9598
container:
9699
image: reactnativecommunity/react-native-android:latest
@@ -108,10 +111,16 @@ jobs:
108111
steps:
109112
- name: Checkout
110113
uses: actions/checkout@v6
114+
- name: Setup node.js for staging cleanup
115+
if: needs.determine_mode.outputs.mode == 'release'
116+
uses: ./.github/actions/setup-node
117+
- name: Drop stale Android staging repository
118+
if: needs.determine_mode.outputs.mode == 'release'
119+
run: node ./scripts/releases-ci/cleanup-maven-staging-repositories.js
111120
- name: Build Android
112121
uses: ./.github/actions/build-android
113122
with:
114-
release-type: nightly
123+
release-type: ${{ needs.determine_mode.outputs.release-type }}
115124
gradle-cache-encryption-key: ${{ secrets.GRADLE_CACHE_ENCRYPTION_KEY }}
116125

117126
# ─── Build + Publish: react-native + all @react-native/* packages
@@ -124,16 +133,14 @@ jobs:
124133
prebuild_apple_dependencies,
125134
prebuild_react_native_core,
126135
]
127-
# For nightly, also wait on build_android. Use always() so this
128-
# job isn't skipped when build_android is skipped (release mode).
129-
# The explicit status checks below handle the real gating.
136+
# Use always() so the explicit status checks below control the gating.
130137
if: |
131138
always() &&
132139
(needs.determine_mode.outputs.mode == 'release' || needs.determine_mode.outputs.mode == 'nightly') &&
133140
needs.determine_mode.result == 'success' &&
141+
needs.build_android.result == 'success' &&
134142
needs.prebuild_apple_dependencies.result == 'success' &&
135-
needs.prebuild_react_native_core.result == 'success' &&
136-
(needs.determine_mode.outputs.mode == 'release' || needs.build_android.result == 'success')
143+
needs.prebuild_react_native_core.result == 'success'
137144
runs-on: ubuntu-latest
138145
environment: npm-publish
139146
# `id-token: write` is required so the npm CLI can mint the OIDC
@@ -169,6 +176,28 @@ jobs:
169176
release-type: ${{ needs.determine_mode.outputs.release-type }}
170177
gradle-cache-encryption-key: ${{ secrets.GRADLE_CACHE_ENCRYPTION_KEY }}
171178

179+
# ─── Release-only: remove an unclosed Android staging repository
180+
# whenever the gated publish job does not complete successfully ─
181+
cleanup_android_staging_repository:
182+
needs: [determine_mode, build_android, publish_react_native]
183+
if: |
184+
always() &&
185+
needs.determine_mode.result == 'success' &&
186+
needs.determine_mode.outputs.mode == 'release' &&
187+
needs.build_android.result != 'skipped' &&
188+
needs.publish_react_native.result != 'success'
189+
runs-on: ubuntu-latest
190+
env:
191+
ORG_GRADLE_PROJECT_SONATYPE_USERNAME: ${{ secrets.ORG_GRADLE_PROJECT_SONATYPE_USERNAME }}
192+
ORG_GRADLE_PROJECT_SONATYPE_PASSWORD: ${{ secrets.ORG_GRADLE_PROJECT_SONATYPE_PASSWORD }}
193+
steps:
194+
- name: Checkout
195+
uses: actions/checkout@v6
196+
- name: Setup node.js
197+
uses: ./.github/actions/setup-node
198+
- name: Drop Android staging repository
199+
run: node ./scripts/releases-ci/cleanup-maven-staging-repositories.js
200+
172201
# ─── Publish bumped monorepo packages (main/stable push) ─────────
173202
publish_bumped_packages:
174203
needs: [determine_mode]

build.gradle.kts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,12 @@ val ndkPath by extra(System.getenv("ANDROID_NDK"))
5353
val ndkVersion by extra(System.getenv("ANDROID_NDK_VERSION") ?: libs.versions.ndkVersion.get())
5454
val sonatypeUsername = findProperty("SONATYPE_USERNAME")?.toString()
5555
val sonatypePassword = findProperty("SONATYPE_PASSWORD")?.toString()
56+
val sonatypeRepositoryDescription = findProperty("SONATYPE_REPOSITORY_DESCRIPTION")?.toString()
5657

5758
nexusPublishing {
59+
if (sonatypeRepositoryDescription != null) {
60+
repositoryDescription.set(sonatypeRepositoryDescription)
61+
}
5862
repositories {
5963
sonatype {
6064
username.set(sonatypeUsername)

scripts/releases-ci/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ Prepares files within the `react-native` package and template for the target rel
1414

1515
Prepares release artifacts and publishes the `react-native` package to npm.
1616

17+
### `cleanup-maven-staging-repositories`
18+
19+
Drops Sonatype staging repositories that exactly match the description in
20+
`ORG_GRADLE_PROJECT_SONATYPE_REPOSITORY_DESCRIPTION`, along with any associated
21+
unpublished Central Portal deployments.
22+
1723
### `publish-updated-packages`
1824

1925
Publishes all updated packages (excluding `react-native`) to npm. Triggered when a commit on a release branch contains `#publish-packages-to-npm`.
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow strict-local
8+
* @format
9+
*/
10+
11+
'use strict';
12+
13+
const {
14+
cleanupMavenStagingRepositories,
15+
createAuthorizationHeader,
16+
} = require('../cleanup-maven-staging-repositories');
17+
18+
const fetchMock = jest.fn();
19+
20+
// $FlowFixMe[cannot-write]
21+
global.fetch = fetchMock;
22+
23+
const API_URL = 'https://sonatype.example/';
24+
const PORTAL_API_URL = 'https://portal.example/';
25+
const CREDENTIALS = {username: 'token-user', password: 'token-password'};
26+
const DESCRIPTION = 'react-native:v0.86.2:github-run-1234';
27+
28+
describe('cleanupMavenStagingRepositories', () => {
29+
beforeEach(() => {
30+
jest.spyOn(console, 'log').mockImplementation(() => {});
31+
jest.resetAllMocks();
32+
});
33+
34+
test('drops matching repositories in every state', async () => {
35+
fetchMock.mockResolvedValueOnce({
36+
ok: true,
37+
json: async () => ({
38+
repositories: [
39+
{key: 'matching-1', state: 'open', description: DESCRIPTION},
40+
{key: 'other-run', state: 'open', description: 'another run'},
41+
{key: 'matching-2', state: 'open', description: DESCRIPTION},
42+
{key: 'already-closed', state: 'closed', description: DESCRIPTION},
43+
{
44+
key: 'already-released',
45+
state: 'released',
46+
description: DESCRIPTION,
47+
},
48+
],
49+
}),
50+
});
51+
fetchMock.mockResolvedValue({ok: true});
52+
53+
await expect(
54+
cleanupMavenStagingRepositories(DESCRIPTION, CREDENTIALS, API_URL),
55+
).resolves.toBe(4);
56+
57+
expect(fetchMock).toHaveBeenCalledTimes(5);
58+
expect(
59+
fetchMock.mock.calls.map(([url, options]) => [
60+
url.toString(),
61+
options.method,
62+
]),
63+
).toEqual([
64+
['https://sonatype.example/manual/search/repositories?ip=any', 'GET'],
65+
['https://sonatype.example/manual/drop/repository/matching-1', 'DELETE'],
66+
['https://sonatype.example/manual/drop/repository/matching-2', 'DELETE'],
67+
[
68+
'https://sonatype.example/manual/drop/repository/already-closed',
69+
'DELETE',
70+
],
71+
[
72+
'https://sonatype.example/manual/drop/repository/already-released',
73+
'DELETE',
74+
],
75+
]);
76+
expect(fetchMock.mock.calls[0][1].headers).toEqual({
77+
Authorization: createAuthorizationHeader(CREDENTIALS),
78+
});
79+
});
80+
81+
test('does nothing when no repository matches', async () => {
82+
fetchMock.mockResolvedValueOnce({
83+
ok: true,
84+
json: async () => ({repositories: []}),
85+
});
86+
87+
await expect(
88+
cleanupMavenStagingRepositories(DESCRIPTION, CREDENTIALS, API_URL),
89+
).resolves.toBe(0);
90+
91+
expect(fetchMock).toHaveBeenCalledTimes(1);
92+
});
93+
94+
test('waits for validation and drops the associated Portal deployment', async () => {
95+
fetchMock.mockResolvedValueOnce({
96+
ok: true,
97+
json: async () => ({
98+
repositories: [
99+
{
100+
key: 'closed-repository',
101+
state: 'closed',
102+
description: DESCRIPTION,
103+
portal_deployment_id: 'deployment-1',
104+
},
105+
],
106+
}),
107+
});
108+
fetchMock.mockResolvedValueOnce({
109+
ok: true,
110+
json: async () => ({deploymentState: 'VALIDATING'}),
111+
});
112+
fetchMock.mockResolvedValueOnce({
113+
ok: true,
114+
json: async () => ({deploymentState: 'VALIDATED'}),
115+
});
116+
fetchMock.mockResolvedValue({ok: true});
117+
118+
await expect(
119+
cleanupMavenStagingRepositories(
120+
DESCRIPTION,
121+
CREDENTIALS,
122+
API_URL,
123+
PORTAL_API_URL,
124+
0,
125+
2,
126+
),
127+
).resolves.toBe(1);
128+
129+
expect(
130+
fetchMock.mock.calls.map(([url, options]) => [
131+
url.toString(),
132+
options.method,
133+
]),
134+
).toEqual([
135+
['https://sonatype.example/manual/search/repositories?ip=any', 'GET'],
136+
[
137+
'https://portal.example/api/v1/publisher/status?id=deployment-1',
138+
'POST',
139+
],
140+
[
141+
'https://portal.example/api/v1/publisher/status?id=deployment-1',
142+
'POST',
143+
],
144+
[
145+
'https://portal.example/api/v1/publisher/deployment/deployment-1',
146+
'DELETE',
147+
],
148+
[
149+
'https://sonatype.example/manual/drop/repository/closed-repository',
150+
'DELETE',
151+
],
152+
]);
153+
});
154+
155+
test('keeps an already published Portal deployment', async () => {
156+
fetchMock.mockResolvedValueOnce({
157+
ok: true,
158+
json: async () => ({
159+
repositories: [
160+
{
161+
key: 'released-repository',
162+
state: 'released',
163+
description: DESCRIPTION,
164+
portal_deployment_id: 'deployment-2',
165+
},
166+
],
167+
}),
168+
});
169+
fetchMock.mockResolvedValueOnce({
170+
ok: true,
171+
json: async () => ({deploymentState: 'PUBLISHED'}),
172+
});
173+
fetchMock.mockResolvedValueOnce({ok: true});
174+
175+
await expect(
176+
cleanupMavenStagingRepositories(
177+
DESCRIPTION,
178+
CREDENTIALS,
179+
API_URL,
180+
PORTAL_API_URL,
181+
),
182+
).resolves.toBe(1);
183+
184+
expect(
185+
fetchMock.mock.calls.map(([url, options]) => [
186+
url.toString(),
187+
options.method,
188+
]),
189+
).toEqual([
190+
['https://sonatype.example/manual/search/repositories?ip=any', 'GET'],
191+
[
192+
'https://portal.example/api/v1/publisher/status?id=deployment-2',
193+
'POST',
194+
],
195+
[
196+
'https://sonatype.example/manual/drop/repository/released-repository',
197+
'DELETE',
198+
],
199+
]);
200+
});
201+
202+
test('fails when Sonatype returns an error', async () => {
203+
fetchMock.mockResolvedValueOnce({
204+
ok: false,
205+
status: 503,
206+
text: async () => 'temporarily unavailable',
207+
});
208+
209+
await expect(
210+
cleanupMavenStagingRepositories(DESCRIPTION, CREDENTIALS, API_URL),
211+
).rejects.toThrow(
212+
'Sonatype request to /manual/search/repositories failed with HTTP 503: temporarily unavailable',
213+
);
214+
});
215+
216+
test('rejects an invalid search response', async () => {
217+
fetchMock.mockResolvedValueOnce({
218+
ok: true,
219+
json: async () => ({}),
220+
});
221+
222+
await expect(
223+
cleanupMavenStagingRepositories(DESCRIPTION, CREDENTIALS, API_URL),
224+
).rejects.toThrow(
225+
'Sonatype returned an invalid repository search response.',
226+
);
227+
});
228+
});

0 commit comments

Comments
 (0)