Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/actions/build-android/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,10 @@ runs:
export HERMES_PREBUILT_FLAG="ORG_GRADLE_PROJECT_react.internal.useHermesNightly=true"
TASKS="publishAllToMavenTempLocal publishAndroidToSonatype build"
else
# release: we want to build all archs (default)
# release: build all archs and close the repository so that Sonatype can validate it.
# Releasing to Maven Central happens only after the Apple builds succeed.
export HERMES_PREBUILT_FLAG="ORG_GRADLE_PROJECT_react.internal.useHermesStable=true"
TASKS="publishAllToMavenTempLocal publishAndroidToSonatype build"
TASKS="publishAllToMavenTempLocal publishAndroidToSonatype closeSonatypeStagingRepository build"
fi
env "$HERMES_PREBUILT_FLAG" ./gradlew $TASKS -PenableWarningsAsErrors=true
- name: Save Android ccache
Expand Down
47 changes: 38 additions & 9 deletions .github/workflows/publish-npm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ on:
permissions:
contents: read

env:
# Stable across partial reruns so later jobs can find the repository created by build_android.
ORG_GRADLE_PROJECT_SONATYPE_REPOSITORY_DESCRIPTION: 'react-native:${{ github.ref_name }}:github-run-${{ github.run_id }}'

jobs:
# ─── Determine what kind of publish this is ──────────────────────
determine_mode:
Expand Down Expand Up @@ -86,11 +90,10 @@ jobs:
use-hermes-prebuilt: ${{ needs.determine_mode.outputs.mode == 'nightly' }}
version-type: ${{ needs.determine_mode.outputs.mode == 'nightly' && 'nightly' || '' }}

# ─── Android build (nightly only — releases handle this in the
# build-npm-package action's Gradle step) ─────────────────────
# ─── Android build (release + nightly) ───────────────────────────
build_android:
needs: [determine_mode]
if: needs.determine_mode.outputs.mode == 'nightly'
if: needs.determine_mode.outputs.mode == 'release' || needs.determine_mode.outputs.mode == 'nightly'
runs-on: ubuntu-latest
container:
image: reactnativecommunity/react-native-android:latest
Expand All @@ -108,10 +111,16 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup node.js for staging cleanup
if: needs.determine_mode.outputs.mode == 'release'
uses: ./.github/actions/setup-node
- name: Drop stale Android staging repository
if: needs.determine_mode.outputs.mode == 'release'
run: node ./scripts/releases-ci/cleanup-maven-staging-repositories.js
- name: Build Android
uses: ./.github/actions/build-android
with:
release-type: nightly
release-type: ${{ needs.determine_mode.outputs.release-type }}
gradle-cache-encryption-key: ${{ secrets.GRADLE_CACHE_ENCRYPTION_KEY }}

# ─── Build + Publish: react-native + all @react-native/* packages
Expand All @@ -124,16 +133,14 @@ jobs:
prebuild_apple_dependencies,
prebuild_react_native_core,
]
# For nightly, also wait on build_android. Use always() so this
# job isn't skipped when build_android is skipped (release mode).
# The explicit status checks below handle the real gating.
# Use always() so the explicit status checks below control the gating.
if: |
always() &&
(needs.determine_mode.outputs.mode == 'release' || needs.determine_mode.outputs.mode == 'nightly') &&
needs.determine_mode.result == 'success' &&
needs.build_android.result == 'success' &&
needs.prebuild_apple_dependencies.result == 'success' &&
needs.prebuild_react_native_core.result == 'success' &&
(needs.determine_mode.outputs.mode == 'release' || needs.build_android.result == 'success')
needs.prebuild_react_native_core.result == 'success'
runs-on: ubuntu-latest
environment: npm-publish
# `id-token: write` is required so the npm CLI can mint the OIDC
Expand Down Expand Up @@ -169,6 +176,28 @@ jobs:
release-type: ${{ needs.determine_mode.outputs.release-type }}
gradle-cache-encryption-key: ${{ secrets.GRADLE_CACHE_ENCRYPTION_KEY }}

# ─── Release-only: remove an unclosed Android staging repository
# whenever the gated publish job does not complete successfully ─
cleanup_android_staging_repository:
needs: [determine_mode, build_android, publish_react_native]
if: |
always() &&
needs.determine_mode.result == 'success' &&
needs.determine_mode.outputs.mode == 'release' &&
needs.build_android.result != 'skipped' &&
needs.publish_react_native.result != 'success'
runs-on: ubuntu-latest
env:
ORG_GRADLE_PROJECT_SONATYPE_USERNAME: ${{ secrets.ORG_GRADLE_PROJECT_SONATYPE_USERNAME }}
ORG_GRADLE_PROJECT_SONATYPE_PASSWORD: ${{ secrets.ORG_GRADLE_PROJECT_SONATYPE_PASSWORD }}
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup node.js
uses: ./.github/actions/setup-node
- name: Drop Android staging repository
run: node ./scripts/releases-ci/cleanup-maven-staging-repositories.js

# ─── Publish bumped monorepo packages (main/stable push) ─────────
publish_bumped_packages:
needs: [determine_mode]
Expand Down
4 changes: 4 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,12 @@ val ndkPath by extra(System.getenv("ANDROID_NDK"))
val ndkVersion by extra(System.getenv("ANDROID_NDK_VERSION") ?: libs.versions.ndkVersion.get())
val sonatypeUsername = findProperty("SONATYPE_USERNAME")?.toString()
val sonatypePassword = findProperty("SONATYPE_PASSWORD")?.toString()
val sonatypeRepositoryDescription = findProperty("SONATYPE_REPOSITORY_DESCRIPTION")?.toString()

nexusPublishing {
if (sonatypeRepositoryDescription != null) {
repositoryDescription.set(sonatypeRepositoryDescription)
}
repositories {
sonatype {
username.set(sonatypeUsername)
Expand Down
6 changes: 6 additions & 0 deletions scripts/releases-ci/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ Prepares files within the `react-native` package and template for the target rel

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

### `cleanup-maven-staging-repositories`

Drops Sonatype staging repositories that exactly match the description in
`ORG_GRADLE_PROJECT_SONATYPE_REPOSITORY_DESCRIPTION`, along with any associated
unpublished Central Portal deployments.

### `publish-updated-packages`

Publishes all updated packages (excluding `react-native`) to npm. Triggered when a commit on a release branch contains `#publish-packages-to-npm`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
* @format
*/

'use strict';

const {
cleanupMavenStagingRepositories,
createAuthorizationHeader,
} = require('../cleanup-maven-staging-repositories');

const fetchMock = jest.fn();

// $FlowFixMe[cannot-write]
global.fetch = fetchMock;

const API_URL = 'https://sonatype.example/';
const PORTAL_API_URL = 'https://portal.example/';
const CREDENTIALS = {username: 'token-user', password: 'token-password'};
const DESCRIPTION = 'react-native:v0.86.2:github-run-1234';

describe('cleanupMavenStagingRepositories', () => {
beforeEach(() => {
jest.spyOn(console, 'log').mockImplementation(() => {});
jest.resetAllMocks();
});

test('drops matching repositories in every state', async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({
repositories: [
{key: 'matching-1', state: 'open', description: DESCRIPTION},
{key: 'other-run', state: 'open', description: 'another run'},
{key: 'matching-2', state: 'open', description: DESCRIPTION},
{key: 'already-closed', state: 'closed', description: DESCRIPTION},
{
key: 'already-released',
state: 'released',
description: DESCRIPTION,
},
],
}),
});
fetchMock.mockResolvedValue({ok: true});

await expect(
cleanupMavenStagingRepositories(DESCRIPTION, CREDENTIALS, API_URL),
).resolves.toBe(4);

expect(fetchMock).toHaveBeenCalledTimes(5);
expect(
fetchMock.mock.calls.map(([url, options]) => [
url.toString(),
options.method,
]),
).toEqual([
['https://sonatype.example/manual/search/repositories?ip=any', 'GET'],
['https://sonatype.example/manual/drop/repository/matching-1', 'DELETE'],
['https://sonatype.example/manual/drop/repository/matching-2', 'DELETE'],
[
'https://sonatype.example/manual/drop/repository/already-closed',
'DELETE',
],
[
'https://sonatype.example/manual/drop/repository/already-released',
'DELETE',
],
]);
expect(fetchMock.mock.calls[0][1].headers).toEqual({
Authorization: createAuthorizationHeader(CREDENTIALS),
});
});

test('does nothing when no repository matches', async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({repositories: []}),
});

await expect(
cleanupMavenStagingRepositories(DESCRIPTION, CREDENTIALS, API_URL),
).resolves.toBe(0);

expect(fetchMock).toHaveBeenCalledTimes(1);
});

test('waits for validation and drops the associated Portal deployment', async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({
repositories: [
{
key: 'closed-repository',
state: 'closed',
description: DESCRIPTION,
portal_deployment_id: 'deployment-1',
},
],
}),
});
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({deploymentState: 'VALIDATING'}),
});
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({deploymentState: 'VALIDATED'}),
});
fetchMock.mockResolvedValue({ok: true});

await expect(
cleanupMavenStagingRepositories(
DESCRIPTION,
CREDENTIALS,
API_URL,
PORTAL_API_URL,
0,
2,
),
).resolves.toBe(1);

expect(
fetchMock.mock.calls.map(([url, options]) => [
url.toString(),
options.method,
]),
).toEqual([
['https://sonatype.example/manual/search/repositories?ip=any', 'GET'],
[
'https://portal.example/api/v1/publisher/status?id=deployment-1',
'POST',
],
[
'https://portal.example/api/v1/publisher/status?id=deployment-1',
'POST',
],
[
'https://portal.example/api/v1/publisher/deployment/deployment-1',
'DELETE',
],
[
'https://sonatype.example/manual/drop/repository/closed-repository',
'DELETE',
],
]);
});

test('keeps an already published Portal deployment', async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({
repositories: [
{
key: 'released-repository',
state: 'released',
description: DESCRIPTION,
portal_deployment_id: 'deployment-2',
},
],
}),
});
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({deploymentState: 'PUBLISHED'}),
});
fetchMock.mockResolvedValueOnce({ok: true});

await expect(
cleanupMavenStagingRepositories(
DESCRIPTION,
CREDENTIALS,
API_URL,
PORTAL_API_URL,
),
).resolves.toBe(1);

expect(
fetchMock.mock.calls.map(([url, options]) => [
url.toString(),
options.method,
]),
).toEqual([
['https://sonatype.example/manual/search/repositories?ip=any', 'GET'],
[
'https://portal.example/api/v1/publisher/status?id=deployment-2',
'POST',
],
[
'https://sonatype.example/manual/drop/repository/released-repository',
'DELETE',
],
]);
});

test('fails when Sonatype returns an error', async () => {
fetchMock.mockResolvedValueOnce({
ok: false,
status: 503,
text: async () => 'temporarily unavailable',
});

await expect(
cleanupMavenStagingRepositories(DESCRIPTION, CREDENTIALS, API_URL),
).rejects.toThrow(
'Sonatype request to /manual/search/repositories failed with HTTP 503: temporarily unavailable',
);
});

test('rejects an invalid search response', async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({}),
});

await expect(
cleanupMavenStagingRepositories(DESCRIPTION, CREDENTIALS, API_URL),
).rejects.toThrow(
'Sonatype returned an invalid repository search response.',
);
});
});
Loading
Loading