Skip to content

Commit a89dc55

Browse files
committed
Split Android build from release publishing
1 parent 5465647 commit a89dc55

8 files changed

Lines changed: 538 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: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
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
36+
.mockResolvedValueOnce({
37+
ok: true,
38+
json: async () => ({
39+
repositories: [
40+
{key: 'matching-1', state: 'open', description: DESCRIPTION},
41+
{key: 'other-run', state: 'open', description: 'another run'},
42+
{key: 'matching-2', state: 'open', description: DESCRIPTION},
43+
{key: 'already-closed', state: 'closed', description: DESCRIPTION},
44+
{
45+
key: 'already-released',
46+
state: 'released',
47+
description: DESCRIPTION,
48+
},
49+
],
50+
}),
51+
})
52+
.mockResolvedValue({ok: true});
53+
54+
await expect(
55+
cleanupMavenStagingRepositories(DESCRIPTION, CREDENTIALS, API_URL),
56+
).resolves.toBe(4);
57+
58+
expect(fetchMock).toHaveBeenCalledTimes(5);
59+
expect(
60+
fetchMock.mock.calls.map(([url, options]) => [
61+
url.toString(),
62+
options.method,
63+
]),
64+
).toEqual([
65+
['https://sonatype.example/manual/search/repositories?ip=any', 'GET'],
66+
['https://sonatype.example/manual/drop/repository/matching-1', 'DELETE'],
67+
['https://sonatype.example/manual/drop/repository/matching-2', 'DELETE'],
68+
[
69+
'https://sonatype.example/manual/drop/repository/already-closed',
70+
'DELETE',
71+
],
72+
[
73+
'https://sonatype.example/manual/drop/repository/already-released',
74+
'DELETE',
75+
],
76+
]);
77+
expect(fetchMock.mock.calls[0][1].headers).toEqual({
78+
Authorization: createAuthorizationHeader(CREDENTIALS),
79+
});
80+
});
81+
82+
test('does nothing when no repository matches', async () => {
83+
fetchMock.mockResolvedValueOnce({
84+
ok: true,
85+
json: async () => ({repositories: []}),
86+
});
87+
88+
await expect(
89+
cleanupMavenStagingRepositories(DESCRIPTION, CREDENTIALS, API_URL),
90+
).resolves.toBe(0);
91+
92+
expect(fetchMock).toHaveBeenCalledTimes(1);
93+
});
94+
95+
test('waits for validation and drops the associated Portal deployment', async () => {
96+
fetchMock
97+
.mockResolvedValueOnce({
98+
ok: true,
99+
json: async () => ({
100+
repositories: [
101+
{
102+
key: 'closed-repository',
103+
state: 'closed',
104+
description: DESCRIPTION,
105+
portal_deployment_id: 'deployment-1',
106+
},
107+
],
108+
}),
109+
})
110+
.mockResolvedValueOnce({
111+
ok: true,
112+
json: async () => ({deploymentState: 'VALIDATING'}),
113+
})
114+
.mockResolvedValueOnce({
115+
ok: true,
116+
json: async () => ({deploymentState: 'VALIDATED'}),
117+
})
118+
.mockResolvedValue({ok: true});
119+
120+
await expect(
121+
cleanupMavenStagingRepositories(
122+
DESCRIPTION,
123+
CREDENTIALS,
124+
API_URL,
125+
PORTAL_API_URL,
126+
0,
127+
2,
128+
),
129+
).resolves.toBe(1);
130+
131+
expect(
132+
fetchMock.mock.calls.map(([url, options]) => [
133+
url.toString(),
134+
options.method,
135+
]),
136+
).toEqual([
137+
['https://sonatype.example/manual/search/repositories?ip=any', 'GET'],
138+
[
139+
'https://portal.example/api/v1/publisher/status?id=deployment-1',
140+
'POST',
141+
],
142+
[
143+
'https://portal.example/api/v1/publisher/status?id=deployment-1',
144+
'POST',
145+
],
146+
[
147+
'https://portal.example/api/v1/publisher/deployment/deployment-1',
148+
'DELETE',
149+
],
150+
[
151+
'https://sonatype.example/manual/drop/repository/closed-repository',
152+
'DELETE',
153+
],
154+
]);
155+
});
156+
157+
test('keeps an already published Portal deployment', async () => {
158+
fetchMock
159+
.mockResolvedValueOnce({
160+
ok: true,
161+
json: async () => ({
162+
repositories: [
163+
{
164+
key: 'released-repository',
165+
state: 'released',
166+
description: DESCRIPTION,
167+
portal_deployment_id: 'deployment-2',
168+
},
169+
],
170+
}),
171+
})
172+
.mockResolvedValueOnce({
173+
ok: true,
174+
json: async () => ({deploymentState: 'PUBLISHED'}),
175+
})
176+
.mockResolvedValueOnce({ok: true});
177+
178+
await expect(
179+
cleanupMavenStagingRepositories(
180+
DESCRIPTION,
181+
CREDENTIALS,
182+
API_URL,
183+
PORTAL_API_URL,
184+
),
185+
).resolves.toBe(1);
186+
187+
expect(
188+
fetchMock.mock.calls.map(([url, options]) => [
189+
url.toString(),
190+
options.method,
191+
]),
192+
).toEqual([
193+
['https://sonatype.example/manual/search/repositories?ip=any', 'GET'],
194+
[
195+
'https://portal.example/api/v1/publisher/status?id=deployment-2',
196+
'POST',
197+
],
198+
[
199+
'https://sonatype.example/manual/drop/repository/released-repository',
200+
'DELETE',
201+
],
202+
]);
203+
});
204+
205+
test('fails when Sonatype returns an error', async () => {
206+
fetchMock.mockResolvedValueOnce({
207+
ok: false,
208+
status: 503,
209+
text: async () => 'temporarily unavailable',
210+
});
211+
212+
await expect(
213+
cleanupMavenStagingRepositories(DESCRIPTION, CREDENTIALS, API_URL),
214+
).rejects.toThrow(
215+
'Sonatype request to /manual/search/repositories failed with HTTP 503: temporarily unavailable',
216+
);
217+
});
218+
219+
test('rejects an invalid search response', async () => {
220+
fetchMock.mockResolvedValueOnce({
221+
ok: true,
222+
json: async () => ({}),
223+
});
224+
225+
await expect(
226+
cleanupMavenStagingRepositories(DESCRIPTION, CREDENTIALS, API_URL),
227+
).rejects.toThrow(
228+
'Sonatype returned an invalid repository search response.',
229+
);
230+
});
231+
});

0 commit comments

Comments
 (0)