diff --git a/.github/actions/build-android/action.yml b/.github/actions/build-android/action.yml index 42f12de540ef..da5a2a5ec71a 100644 --- a/.github/actions/build-android/action.yml +++ b/.github/actions/build-android/action.yml @@ -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 diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index fad0e0b3a47d..a3e097774b32 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -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: @@ -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 @@ -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 @@ -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 @@ -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] diff --git a/build.gradle.kts b/build.gradle.kts index a4badbe79e5e..80b259cc0033 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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) diff --git a/scripts/releases-ci/README.md b/scripts/releases-ci/README.md index a9305c03b71b..5a32c10f77c9 100644 --- a/scripts/releases-ci/README.md +++ b/scripts/releases-ci/README.md @@ -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`. diff --git a/scripts/releases-ci/__tests__/cleanup-maven-staging-repositories-test.js b/scripts/releases-ci/__tests__/cleanup-maven-staging-repositories-test.js new file mode 100644 index 000000000000..9a28bd527c6e --- /dev/null +++ b/scripts/releases-ci/__tests__/cleanup-maven-staging-repositories-test.js @@ -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.', + ); + }); +}); diff --git a/scripts/releases-ci/cleanup-maven-staging-repositories.js b/scripts/releases-ci/cleanup-maven-staging-repositories.js new file mode 100644 index 000000000000..ba23edb71970 --- /dev/null +++ b/scripts/releases-ci/cleanup-maven-staging-repositories.js @@ -0,0 +1,204 @@ +/** + * 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 DEFAULT_API_URL = 'https://ossrh-staging-api.central.sonatype.com/'; +const DEFAULT_PORTAL_API_URL = 'https://central.sonatype.com/'; +const DEFAULT_PORTAL_POLL_INTERVAL_MS = 10_000; +const DEFAULT_PORTAL_STATUS_ATTEMPTS = 60; + +/*:: +type Credentials = { + password: string, + username: string, +}; + +type StagingRepository = { + description?: ?string, + key: string, + portal_deployment_id?: ?string, +}; +*/ + +function createAuthorizationHeader( + credentials /*: Credentials */, +) /*: string */ { + const token = Buffer.from( + `${credentials.username}:${credentials.password}`, + ).toString('base64'); + return `Bearer ${token}`; +} + +async function request( + url /*: URL */, + credentials /*: Credentials */, + method /*: string */ = 'GET', +) /*: Promise */ { + const response = await fetch(url, { + method, + headers: { + Authorization: createAuthorizationHeader(credentials), + }, + }); + + if (!response.ok) { + const responseBody = await response.text(); + throw new Error( + `Sonatype request to ${url.pathname} failed with HTTP ${response.status}: ${responseBody}`, + ); + } + + return response; +} + +function sleep(milliseconds /*: number */) /*: Promise */ { + return new Promise(resolve => setTimeout(resolve, milliseconds)); +} + +async function cleanupPortalDeployment( + deploymentId /*: string */, + credentials /*: Credentials */, + portalApiUrl /*: string */, + pollIntervalMs /*: number */, + maxStatusAttempts /*: number */, +) /*: Promise */ { + const statusUrl = new URL('api/v1/publisher/status', portalApiUrl); + statusUrl.searchParams.set('id', deploymentId); + + let deploymentState /*: string */ = ''; + for (let attempt = 1; attempt <= maxStatusAttempts; attempt++) { + const statusResponse = await request(statusUrl, credentials, 'POST'); + const statusResult = await statusResponse.json(); + deploymentState = statusResult.deploymentState; + if (typeof deploymentState !== 'string') { + throw new Error( + `Sonatype returned an invalid status for Portal deployment ${deploymentId}.`, + ); + } + + if (deploymentState !== 'PENDING' && deploymentState !== 'VALIDATING') { + break; + } + if (attempt === maxStatusAttempts) { + throw new Error( + `Portal deployment ${deploymentId} remained ${deploymentState} after ${maxStatusAttempts} status checks.`, + ); + } + if (pollIntervalMs > 0) { + await sleep(pollIntervalMs); + } + } + + if (deploymentState === 'VALIDATED' || deploymentState === 'FAILED') { + const dropUrl = new URL( + `api/v1/publisher/deployment/${encodeURIComponent(deploymentId)}`, + portalApiUrl, + ); + await request(dropUrl, credentials, 'DELETE'); + console.log(`Dropped Central Portal deployment ${deploymentId}.`); + } else if ( + deploymentState === 'PUBLISHING' || + deploymentState === 'PUBLISHED' + ) { + console.log( + `Central Portal deployment ${deploymentId} is already ${deploymentState}; it cannot be dropped.`, + ); + } else { + throw new Error( + `Central Portal deployment ${deploymentId} has unsupported state ${deploymentState}.`, + ); + } +} + +async function cleanupMavenStagingRepositories( + repositoryDescription /*: string */, + credentials /*: Credentials */, + stagingApiUrl /*: string */ = DEFAULT_API_URL, + portalApiUrl /*: string */ = DEFAULT_PORTAL_API_URL, + portalPollIntervalMs /*: number */ = DEFAULT_PORTAL_POLL_INTERVAL_MS, + maxPortalStatusAttempts /*: number */ = DEFAULT_PORTAL_STATUS_ATTEMPTS, +) /*: Promise */ { + if (repositoryDescription === '') { + throw new Error('A Maven staging repository description is required.'); + } + + const searchUrl = new URL('manual/search/repositories', stagingApiUrl); + searchUrl.searchParams.set('ip', 'any'); + + const searchResponse = await request(searchUrl, credentials); + const searchResult = await searchResponse.json(); + if (!Array.isArray(searchResult.repositories)) { + throw new Error('Sonatype returned an invalid repository search response.'); + } + + const matchingRepositories /*: Array */ = + searchResult.repositories.filter( + repository => repository.description === repositoryDescription, + ); + + for (const repository of matchingRepositories) { + if (repository.portal_deployment_id != null) { + await cleanupPortalDeployment( + repository.portal_deployment_id, + credentials, + portalApiUrl, + portalPollIntervalMs, + maxPortalStatusAttempts, + ); + } + + const dropUrl = new URL( + `manual/drop/repository/${encodeURIComponent(repository.key)}`, + stagingApiUrl, + ); + await request(dropUrl, credentials, 'DELETE'); + console.log(`Dropped Maven staging repository ${repository.key}.`); + } + + if (matchingRepositories.length === 0) { + console.log( + `No Maven staging repository matched "${repositoryDescription}".`, + ); + } + + return matchingRepositories.length; +} + +async function main() { + const repositoryDescription = + process.env.ORG_GRADLE_PROJECT_SONATYPE_REPOSITORY_DESCRIPTION; + const username = process.env.ORG_GRADLE_PROJECT_SONATYPE_USERNAME; + const password = process.env.ORG_GRADLE_PROJECT_SONATYPE_PASSWORD; + + if (repositoryDescription == null) { + throw new Error( + 'ORG_GRADLE_PROJECT_SONATYPE_REPOSITORY_DESCRIPTION is required.', + ); + } + if (username == null || password == null) { + throw new Error('Sonatype username and password are required.'); + } + + await cleanupMavenStagingRepositories(repositoryDescription, { + username, + password, + }); +} + +if (require.main === module) { + void main(); +} + +module.exports = { + cleanupPortalDeployment, + cleanupMavenStagingRepositories, + createAuthorizationHeader, +}; diff --git a/scripts/releases/utils/__tests__/release-utils-test.js b/scripts/releases/utils/__tests__/release-utils-test.js new file mode 100644 index 000000000000..a2c029669254 --- /dev/null +++ b/scripts/releases/utils/__tests__/release-utils-test.js @@ -0,0 +1,49 @@ +/** + * 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 echoMock = jest.fn(); +const execMock = jest.fn(); +const exitMock = jest.fn(); +const testMock = jest.fn(); + +jest.mock('shelljs', () => ({ + echo: echoMock, + exec: execMock, + exit: exitMock, + test: testMock, +})); + +const {publishAndroidArtifactsToMaven} = require('../release-utils'); + +describe('publishAndroidArtifactsToMaven', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + test('finds and releases the repository closed by build_android', () => { + execMock.mockReturnValueOnce({code: 0}); + + publishAndroidArtifactsToMaven('0.86.2', 'release'); + + expect(execMock).toHaveBeenCalledWith( + './gradlew findSonatypeStagingRepository releaseSonatypeStagingRepository', + ); + expect(exitMock).not.toHaveBeenCalled(); + }); + + test('does not manage a staging repository for a nightly', () => { + publishAndroidArtifactsToMaven('0.87.0-nightly-20260728', 'nightly'); + + expect(execMock).not.toHaveBeenCalled(); + expect(exitMock).not.toHaveBeenCalled(); + }); +}); diff --git a/scripts/releases/utils/release-utils.js b/scripts/releases/utils/release-utils.js index 207d6ded4ab1..48bca744d935 100644 --- a/scripts/releases/utils/release-utils.js +++ b/scripts/releases/utils/release-utils.js @@ -61,14 +61,14 @@ function publishAndroidArtifactsToMaven( // We want to gate ourselves against accidentally publishing a 1.x or a 1000.x on // maven central which will break the semver for our artifacts. if (buildType === 'release' && releaseVersion.startsWith('0.')) { - // -------- For stable releases, we also need to close and release the staging repository. + // -------- The Android job already uploaded and closed this staging repository. if ( exec( - './gradlew publishAndroidToSonatype closeAndReleaseSonatypeStagingRepository', + './gradlew findSonatypeStagingRepository releaseSonatypeStagingRepository', ).code ) { echo( - 'Failed to close and release the staging repository on Sonatype (Maven Central) for Android artifacts', + 'Failed to release the staging repository on Sonatype (Maven Central) for Android artifacts', ); exit(1); }