From 22840c5442dc2931051c95235152553912de9015 Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Mon, 8 Jun 2026 22:20:15 -0700 Subject: [PATCH] Migrate npm publish to OIDC Trusted Publishing via a reusable workflow (#57099) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Replaces the long-lived `GHA_NPM_TOKEN` automation token with npm Trusted Publishing (OIDC) for every `npm publish` invoked from this repo's GitHub Actions. Why a reusable workflow: npmjs.com Trusted Publishing accepts only ONE (org, repo, workflow_filename, environment) tuple per package. Today, packages are published from multiple workflow files: - `react-native` from publish-release.yml + nightly.yml - every `react-native/*` from nightly.yml + publish-bumped-packages.yml A naive per-workflow OIDC migration would require two Trusted Publisher entries per package, which npm doesn't support. Instead, this diff funnels every `npm publish` through one new file — `.github/workflows/publish-npm.yml` — invoked via `workflow_call` from the existing top-level workflows. The OIDC `job_workflow_ref` claim therefore always resolves to `publish-npm.yml`, so each package needs exactly one Trusted Publisher entry pointing here. What changes: * New `.github/workflows/publish-npm.yml`: reusable workflow with a `mode` input. `mode: react-native` runs the full Android + iOS prebuilt + JS build path (used by release & nightly, publishes `react-native` and — in nightly mode — every `react-native/*` package via `scripts/releases-ci/publish-npm.js`). `mode: monorepo-packages` runs only the JS build and publishes the delta-bumped packages via `scripts/releases-ci/publish-updated-packages.js` (used by publish-bumped-packages.yml). Both jobs grant `id-token: write` so the npm CLI can mint the OIDC token for Trusted Publishing. * `.github/workflows/publish-release.yml`: replace the `build_npm_package` job's inline build/publish steps with a `uses: ./.github/workflows/publish-npm.yml` call. The template-publish, rn-diff-purge, npm-verify, and Maven-verify steps move into a new `post_publish` follow-up job that `needs: [build_npm_package]`. Drops `GHA_NPM_TOKEN` from the env. * `.github/workflows/nightly.yml`: same — `build_npm_package` now delegates to the reusable workflow. Drops `GHA_NPM_TOKEN` and the obsolete `Verify NPM token` precheck (Trusted Publishing has no pre-mintable token to validate; failures surface at `npm publish` time). * `.github/workflows/publish-bumped-packages.yml`: shrinks to a thin trigger wrapper that calls the reusable workflow with `mode: monorepo-packages`. * `.github/workflows/create-release.yml`: drop the obsolete `Verify NPM token` step. * `.github/actions/build-npm-package`: drop the `gha-npm-token` input and the `Set npm credentials` step that wrote `_authToken`. Pass `registry-url: https://registry.npmjs.org` to setup-node so `actions/setup-node@v6` writes a `.npmrc` configured to consume the OIDC token at publish time. * `.github/actions/setup-node`: thread a new `registry-url` input through to `actions/setup-node@v6`. The publish scripts themselves (`scripts/releases-ci/publish-npm.js`, `scripts/releases-ci/publish-updated-packages.js`, `scripts/releases/utils/npm-utils.js`) are unchanged: they shell out to plain `npm publish`, which performs the OIDC exchange transparently when it sees a GitHub Actions OIDC environment and a Trusted Publisher configured for the package on npmjs.com. Note: this diff only changes the workflow definitions. Each package on npmjs.com must additionally be configured with a Trusted Publisher pointing at: - org: facebook - repo: react-native - workflow filename: publish-npm.yml - environment: (blank — none configured) The npm CLI's OIDC exchange returns 404 until that registry-side config is in place. Trusted Publisher entries are additive on npmjs.com (don't enable "Require Trusted Publishing" yet) so the existing token-based flow keeps working through the cutover. See the stack landing notes for the full package list and UI steps. Backport: this also needs picking back to `*-stable` branches before "Require Trusted Publishing" is enabled on any package, since GitHub Actions runs the workflow file from the ref that triggers it. Changelog: [Internal] Reviewed By: cortinico, cipolleschi Differential Revision: D107805971 --- .github/actions/build-npm-package/action.yml | 15 +-- .github/actions/setup-node/action.yml | 8 ++ .github/workflows/create-release.yml | 15 --- .github/workflows/nightly.yml | 57 +++------- .github/workflows/publish-bumped-packages.yml | 26 ++--- .github/workflows/publish-npm.yml | 102 ++++++++++++++++++ .github/workflows/publish-release.yml | 36 +++---- 7 files changed, 153 insertions(+), 106 deletions(-) create mode 100644 .github/workflows/publish-npm.yml diff --git a/.github/actions/build-npm-package/action.yml b/.github/actions/build-npm-package/action.yml index 11cb91db436d..3947642c23c1 100644 --- a/.github/actions/build-npm-package/action.yml +++ b/.github/actions/build-npm-package/action.yml @@ -4,10 +4,6 @@ inputs: release-type: required: true description: The type of release we are building. It could be nightly, release or dry-run - gha-npm-token: - required: false - description: The GHA npm token, required only to publish to npm - default: '' gradle-cache-encryption-key: description: The encryption key needed to store the Gradle Configuration cache skip-apple-prebuilts: @@ -45,6 +41,8 @@ runs: cache-encryption-key: ${{ inputs.gradle-cache-encryption-key }} - name: Setup node.js uses: ./.github/actions/setup-node + with: + registry-url: 'https://registry.npmjs.org' - name: Install dependencies uses: ./.github/actions/yarn-install - name: Build packages @@ -53,12 +51,9 @@ runs: - name: Build types shell: bash run: yarn build-types --skip-snapshot - # Continue with publish steps - - name: Set npm credentials - if: ${{ inputs.release-type == 'release' || - inputs.release-type == 'nightly' }} - shell: bash - run: echo "//registry.npmjs.org/:_authToken=${{ inputs.gha-npm-token }}" > ~/.npmrc + # `npm publish` below authenticates via npm Trusted Publishing (OIDC). + # The caller (the reusable `publish-npm.yml` workflow) MUST grant + # `id-token: write`; this composite action runs inside that job. - name: Publish NPM shell: bash run: | diff --git a/.github/actions/setup-node/action.yml b/.github/actions/setup-node/action.yml index 7b5aa82470a4..0b4884dea872 100644 --- a/.github/actions/setup-node/action.yml +++ b/.github/actions/setup-node/action.yml @@ -5,6 +5,13 @@ inputs: description: 'The node.js version to use' required: false default: '22.14.0' + registry-url: + description: | + Optional npm registry URL passed through to actions/setup-node. Set on + jobs that publish to npm so setup-node writes a `.npmrc` configured to + pick up the OIDC-minted token from npm Trusted Publishing. + required: false + default: '' runs: using: "composite" steps: @@ -13,3 +20,4 @@ runs: with: node-version: ${{ inputs.node-version }} cache: yarn + registry-url: ${{ inputs.registry-url }} diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 705b4e313313..464e9eb3f86c 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -28,21 +28,6 @@ jobs: token: ${{ secrets.REACT_NATIVE_BOT_GITHUB_TOKEN }} fetch-depth: 0 fetch-tags: 'true' - - name: Verify NPM token - run: | - if [[ -z "$GHA_NPM_TOKEN" ]]; then - echo "⚠️ No NPM token found. Skipping validation." - exit 0 - fi - echo "//registry.npmjs.org/:_authToken=$GHA_NPM_TOKEN" > ~/.npmrc - if ! npm whoami > /dev/null 2>&1; then - echo "❌ NPM token is invalid or expired. Aborting release." - exit 1 - fi - echo "✅ NPM token is valid ($(npm whoami))" - rm -f ~/.npmrc - env: - GHA_NPM_TOKEN: ${{ secrets.GHA_NPM_TOKEN }} - name: Check if on stable branch id: check_stable_branch run: | diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index ab1c81068d12..cda224cee532 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -63,8 +63,11 @@ jobs: release-type: ${{ needs.set_release_type.outputs.RELEASE_TYPE }} gradle-cache-encryption-key: ${{ secrets.GRADLE_CACHE_ENCRYPTION_KEY }} + # Delegate the actual npm publish to the shared reusable workflow so + # every `npm publish` in this repo originates from one workflow file — + # required because npm Trusted Publishing only accepts one + # (org, repo, workflow_filename) per package. build_npm_package: - runs-on: 8-core-ubuntu needs: [ set_release_type, @@ -72,42 +75,16 @@ jobs: prebuild_apple_dependencies, prebuild_react_native_core, ] - container: - image: reactnativecommunity/react-native-android:latest - env: - TERM: "dumb" - GRADLE_OPTS: "-Dorg.gradle.daemon=false" - # Set the encoding to resolve a known character encoding issue with decompressing tar.gz files in containers - # via Gradle: https://github.com/gradle/gradle/issues/23391#issuecomment-1878979127 - LC_ALL: C.UTF8 - # By default we only build ARM64 to save time/resources. For release/nightlies, we override this value to build all archs. - ORG_GRADLE_PROJECT_reactNativeArchitectures: "arm64-v8a" - REACT_NATIVE_DOWNLOADS_DIR: /opt/react-native-downloads - env: - GHA_NPM_TOKEN: ${{ secrets.GHA_NPM_TOKEN }} - ORG_GRADLE_PROJECT_SIGNING_PWD: ${{ secrets.ORG_GRADLE_PROJECT_SIGNING_PWD }} - ORG_GRADLE_PROJECT_SIGNING_KEY: ${{ secrets.ORG_GRADLE_PROJECT_SIGNING_KEY }} - 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: Verify NPM token - run: | - if [[ -z "$GHA_NPM_TOKEN" ]]; then - echo "⚠️ No NPM token found. Skipping validation." - exit 0 - fi - echo "//registry.npmjs.org/:_authToken=$GHA_NPM_TOKEN" > ~/.npmrc - if ! npm whoami > /dev/null 2>&1; then - echo "❌ NPM token is invalid or expired. Aborting release." - exit 1 - fi - echo "✅ NPM token is valid ($(npm whoami))" - rm -f ~/.npmrc - - name: Build and Publish NPM Package - uses: ./.github/actions/build-npm-package - with: - release-type: ${{ needs.set_release_type.outputs.RELEASE_TYPE }} - gha-npm-token: ${{ env.GHA_NPM_TOKEN }} - gradle-cache-encryption-key: ${{ secrets.GRADLE_CACHE_ENCRYPTION_KEY }} + # The top-level `permissions: contents: read` is the ceiling for + # GITHUB_TOKEN in every job here, including reusable-workflow calls. + # Re-grant `id-token: write` at the job level so publish-npm.yml's + # `publish-react-native` job can mint the OIDC token that npm + # Trusted Publishing exchanges for a publish token. + permissions: + contents: read + id-token: write + uses: ./.github/workflows/publish-npm.yml + secrets: inherit + with: + mode: react-native + release-type: ${{ needs.set_release_type.outputs.RELEASE_TYPE }} diff --git a/.github/workflows/publish-bumped-packages.yml b/.github/workflows/publish-bumped-packages.yml index cf0b69b8c354..a3576848637b 100644 --- a/.github/workflows/publish-bumped-packages.yml +++ b/.github/workflows/publish-bumped-packages.yml @@ -7,23 +7,13 @@ on: - "*-stable" jobs: + # Delegate to the shared reusable workflow so every `npm publish` in + # this repo originates from one workflow file — required because npm + # Trusted Publishing only accepts one (org, repo, workflow_filename) + # per package. publish_bumped_packages: - runs-on: ubuntu-latest if: github.repository == 'facebook/react-native' - env: - GHA_NPM_TOKEN: ${{ secrets.GHA_NPM_TOKEN }} - steps: - - name: Checkout - uses: actions/checkout@v6 - - name: Setup node.js - uses: ./.github/actions/setup-node - - name: Run Yarn Install - uses: ./.github/actions/yarn-install - - name: Build packages - run: yarn build - - name: Build types - run: yarn build-types --skip-snapshot - - name: Set NPM auth token - run: echo "//registry.npmjs.org/:_authToken=$GHA_NPM_TOKEN" > ~/.npmrc - - name: Find and publish all bumped packages - run: node ./scripts/releases-ci/publish-updated-packages.js + uses: ./.github/workflows/publish-npm.yml + secrets: inherit + with: + mode: monorepo-packages diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml new file mode 100644 index 000000000000..f451b1fd35c5 --- /dev/null +++ b/.github/workflows/publish-npm.yml @@ -0,0 +1,102 @@ +# Reusable workflow that performs every `npm publish` in this repo. +# +# Why this exists: npmjs.com Trusted Publishing accepts only ONE +# (org, repo, workflow_filename, environment) tuple per package. If +# `react-native` were published from `publish-release.yml` AND +# `nightly.yml` directly, we'd need two Trusted Publisher entries per +# package — npm rejects that. By moving every `npm publish` into this +# single reusable workflow file, the OIDC `job_workflow_ref` claim +# always resolves to `publish-npm.yml` regardless of which top-level +# workflow triggered the run, so each package needs exactly one +# Trusted Publisher entry pointing here. +# +# See https://docs.npmjs.com/trusted-publishers and +# https://docs.github.com/en/actions/sharing-automations/reusing-workflows . +name: Publish to npm (reusable) + +on: + workflow_call: + inputs: + mode: + description: | + 'react-native' runs the full Android/iOS-prebuilt + JS build + and publishes via scripts/releases-ci/publish-npm.js (which + publishes `react-native` and, in nightly mode, every + @react-native/* package). 'monorepo-packages' runs only the + JS build and publishes via + scripts/releases-ci/publish-updated-packages.js (delta-based, + gated on a #publish-packages-to-npm commit message). + type: string + required: true + release-type: + description: "For mode=react-native: release | nightly | dry-run." + type: string + required: false + default: "dry-run" + skip-apple-prebuilts: + description: "For mode=react-native: skip downloading prebuilt Apple artifacts." + type: boolean + required: false + default: false + +jobs: + publish-react-native: + if: inputs.mode == 'react-native' + runs-on: 8-core-ubuntu + environment: npm-publish + # `id-token: write` is required so the npm CLI can mint the OIDC + # token that npm Trusted Publishing exchanges for a publish token. + permissions: + contents: read + id-token: write + container: + image: reactnativecommunity/react-native-android:latest + env: + TERM: "dumb" + # Set the encoding to resolve a known character encoding issue with decompressing tar.gz files in containers + # via Gradle: https://github.com/gradle/gradle/issues/23391#issuecomment-1878979127 + LC_ALL: C.UTF8 + GRADLE_OPTS: "-Dorg.gradle.daemon=false" + # By default we only build ARM64 to save time/resources. For release/nightlies, we override this value to build all archs. + ORG_GRADLE_PROJECT_reactNativeArchitectures: "arm64-v8a" + REACT_NATIVE_DOWNLOADS_DIR: /opt/react-native-downloads + env: + ORG_GRADLE_PROJECT_SIGNING_PWD: ${{ secrets.ORG_GRADLE_PROJECT_SIGNING_PWD }} + ORG_GRADLE_PROJECT_SIGNING_KEY: ${{ secrets.ORG_GRADLE_PROJECT_SIGNING_KEY }} + 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 + with: + fetch-depth: 0 + fetch-tags: true + - name: Build and Publish NPM Package + uses: ./.github/actions/build-npm-package + with: + release-type: ${{ inputs.release-type }} + gradle-cache-encryption-key: ${{ secrets.GRADLE_CACHE_ENCRYPTION_KEY }} + skip-apple-prebuilts: ${{ inputs.skip-apple-prebuilts && 'true' || 'false' }} + + publish-monorepo-packages: + if: inputs.mode == 'monorepo-packages' + runs-on: ubuntu-latest + environment: npm-publish + permissions: + contents: read + id-token: write + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Setup node.js + uses: ./.github/actions/setup-node + with: + registry-url: "https://registry.npmjs.org" + - name: Run Yarn Install + uses: ./.github/actions/yarn-install + - name: Build packages + run: yarn build + - name: Build types + run: yarn build-types --skip-snapshot + - name: Find and publish all bumped packages + run: node ./scripts/releases-ci/publish-updated-packages.js diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 0e87cc895cb7..68b421ec5a1e 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -43,31 +43,27 @@ jobs: secrets: inherit needs: [prebuild_apple_dependencies] + # Delegate the actual npm publish to the shared reusable workflow so + # every `npm publish` in this repo originates from one workflow file — + # required because npm Trusted Publishing only accepts one + # (org, repo, workflow_filename) per package. build_npm_package: - runs-on: 8-core-ubuntu needs: [ set_release_type, prebuild_apple_dependencies, prebuild_react_native_core, ] - container: - image: reactnativecommunity/react-native-android:latest - env: - TERM: "dumb" - # Set the encoding to resolve a known character encoding issue with decompressing tar.gz files in containers - # via Gradle: https://github.com/gradle/gradle/issues/23391#issuecomment-1878979127 - LC_ALL: C.UTF8 - GRADLE_OPTS: "-Dorg.gradle.daemon=false" - # By default we only build ARM64 to save time/resources. For release/nightlies, we override this value to build all archs. - ORG_GRADLE_PROJECT_reactNativeArchitectures: "arm64-v8a" - REACT_NATIVE_DOWNLOADS_DIR: /opt/react-native-downloads + uses: ./.github/workflows/publish-npm.yml + secrets: inherit + with: + mode: react-native + release-type: ${{ needs.set_release_type.outputs.RELEASE_TYPE }} + + post_publish: + runs-on: ubuntu-latest + needs: [build_npm_package] env: - GHA_NPM_TOKEN: ${{ secrets.GHA_NPM_TOKEN }} - ORG_GRADLE_PROJECT_SIGNING_PWD: ${{ secrets.ORG_GRADLE_PROJECT_SIGNING_PWD }} - ORG_GRADLE_PROJECT_SIGNING_KEY: ${{ secrets.ORG_GRADLE_PROJECT_SIGNING_KEY }} - ORG_GRADLE_PROJECT_SONATYPE_USERNAME: ${{ secrets.ORG_GRADLE_PROJECT_SONATYPE_USERNAME }} - ORG_GRADLE_PROJECT_SONATYPE_PASSWORD: ${{ secrets.ORG_GRADLE_PROJECT_SONATYPE_PASSWORD }} REACT_NATIVE_BOT_GITHUB_TOKEN: ${{ secrets.REACT_NATIVE_BOT_GITHUB_TOKEN }} steps: - name: Checkout @@ -75,12 +71,6 @@ jobs: with: fetch-depth: 0 fetch-tags: true - - name: Build and Publish NPM Package - uses: ./.github/actions/build-npm-package - with: - release-type: ${{ needs.set_release_type.outputs.RELEASE_TYPE }} - gha-npm-token: ${{ env.GHA_NPM_TOKEN }} - gradle-cache-encryption-key: ${{ secrets.GRADLE_CACHE_ENCRYPTION_KEY }} - name: Publish @react-native-community/template id: publish-template-to-npm uses: actions/github-script@v8