Skip to content

Commit 81c8890

Browse files
committed
fix: switch npm Trusted Publishing from workflow_call to workflow_dispatch
## Summary npm Trusted Publishing matches the `workflow_ref` OIDC claim, which always resolves to the **top-level/caller** workflow — not the reusable child workflow invoked via `workflow_call`. The prior migration (#57099) assumed npm matched `job_workflow_ref` (the reusable workflow), causing a 404 from npm's OIDC token exchange because the configured Trusted Publisher entry (`publish-npm.yml`) never matches the actual `workflow_ref` claim (`publish-release.yml`, `nightly.yml`, or `publish-bumped-packages.yml`). This PR fixes the issue by switching `publish-npm.yml` from `workflow_call` (reusable) to `workflow_dispatch`, making it always the top-level workflow. The callers now: 1. **Build + pack** — run `npm pack` (via `--pack-only` flag) instead of `npm publish`, producing tarballs + a `manifest.json` 2. **Upload** — store tarballs as a workflow artifact 3. **Dispatch** — fire `publish-npm.yml` via `workflow_dispatch`, passing the `source-run-id` so it can download the tarballs `publish-npm.yml` then downloads the tarballs and publishes each one with `npm publish <tarball> --provenance`. ## Changes - **publish-npm.yml**: Rewritten from `workflow_call` to `workflow_dispatch`. Downloads tarballs from the source run and publishes via the new `publish-from-tarballs.js` script. - **publish-from-tarballs.js** (new): Reads `manifest.json`, publishes each tarball with `--provenance`, retries once on failure. - **npm-utils.js**: Added `packPackage()` function that runs `npm pack` and returns the tarball filename. - **publish-npm.js**: Added `--pack-only` / `-p` flag. When set, runs `npm pack` instead of `npm publish` for each package and writes `npm-tarballs/manifest.json`. - **publish-updated-packages.js**: Added `--pack-only` flag with same behaviour. - **build-npm-package action**: Added `pack-only` input. When true, passes `--pack-only` to publish-npm.js and uploads the tarballs artifact. Only sets `registry-url` when not in pack-only mode. - **publish-release.yml**: Inlined the build job (was delegating to `publish-npm.yml`), added `dispatch_npm_publish` job, increased npm verification timeout to 10 minutes. - **nightly.yml**: Same pattern — inlined build, added dispatch. Removed `id-token: write` permission (no longer needed here). - **publish-bumped-packages.yml**: Inlined build + pack, added dispatch. Checks for tarballs artifact before dispatching. ## npm Trusted Publisher config (manual step) For each of the 24 packages, configure on npmjs.com: - Organization: `react` - Repository: `react-native` - Workflow filename: `publish-npm.yml` - Environment: `npm-publish`
1 parent 79adce3 commit 81c8890

9 files changed

Lines changed: 483 additions & 188 deletions

File tree

.github/actions/build-npm-package/action.yml

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
name: build-npm-package
2-
description: This action builds the NPM package and uploads it to Maven
2+
description: This action builds the NPM package and publishes to Maven. In pack-only mode it produces tarballs for deferred npm publish.
33
inputs:
44
release-type:
55
required: true
@@ -10,6 +10,10 @@ inputs:
1010
description: When true, skip downloading prebuilt Apple artifacts (use when Apple prebuild jobs were skipped)
1111
required: false
1212
default: 'false'
13+
pack-only:
14+
description: When true, run `npm pack` instead of `npm publish` and output tarballs + manifest.json for deferred publishing via publish-npm.yml.
15+
required: false
16+
default: 'false'
1317

1418
runs:
1519
using: composite
@@ -42,7 +46,7 @@ runs:
4246
- name: Setup node.js
4347
uses: ./.github/actions/setup-node
4448
with:
45-
registry-url: 'https://registry.npmjs.org'
49+
registry-url: ${{ inputs.pack-only != 'true' && 'https://registry.npmjs.org' || '' }}
4650
- name: Install dependencies
4751
uses: ./.github/actions/yarn-install
4852
- name: Build packages
@@ -51,10 +55,7 @@ runs:
5155
- name: Build types
5256
shell: bash
5357
run: yarn build-types --skip-snapshot
54-
# `npm publish` below authenticates via npm Trusted Publishing (OIDC).
55-
# The caller (the reusable `publish-npm.yml` workflow) MUST grant
56-
# `id-token: write`; this composite action runs inside that job.
57-
- name: Publish NPM
58+
- name: Build and pack/publish NPM
5859
shell: bash
5960
run: |
6061
echo "GRADLE_OPTS = $GRADLE_OPTS"
@@ -65,7 +66,17 @@ runs:
6566
else
6667
export ORG_GRADLE_PROJECT_reactNativeArchitectures="armeabi-v7a,arm64-v8a,x86,x86_64"
6768
fi
68-
node ./scripts/releases-ci/publish-npm.js -t ${{ inputs.release-type }}
69+
PACK_FLAG=""
70+
if [[ "${{ inputs.pack-only }}" == "true" ]]; then
71+
PACK_FLAG="--pack-only"
72+
fi
73+
node ./scripts/releases-ci/publish-npm.js -t ${{ inputs.release-type }} $PACK_FLAG
74+
- name: Upload npm tarballs
75+
if: ${{ inputs.pack-only == 'true' }}
76+
uses: actions/upload-artifact@v6
77+
with:
78+
name: npm-tarballs
79+
path: npm-tarballs
6980
- name: Upload npm logs
7081
uses: actions/upload-artifact@v6
7182
with:

.github/workflows/nightly.yml

Lines changed: 49 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,6 @@ jobs:
4545
image: reactnativecommunity/react-native-android:latest
4646
env:
4747
TERM: "dumb"
48-
# Set the encoding to resolve a known character encoding issue with decompressing tar.gz files in containers
49-
# via Gradle: https://github.com/gradle/gradle/issues/23391#issuecomment-1878979127
5048
LC_ALL: C.UTF8
5149
GRADLE_OPTS: "-Dorg.gradle.daemon=false"
5250
ORG_GRADLE_PROJECT_SIGNING_PWD: ${{ secrets.ORG_GRADLE_PROJECT_SIGNING_PWD }}
@@ -63,28 +61,61 @@ jobs:
6361
release-type: ${{ needs.set_release_type.outputs.RELEASE_TYPE }}
6462
gradle-cache-encryption-key: ${{ secrets.GRADLE_CACHE_ENCRYPTION_KEY }}
6563

66-
# Delegate the actual npm publish to the shared reusable workflow so
67-
# every `npm publish` in this repo originates from one workflow file —
68-
# required because npm Trusted Publishing only accepts one
69-
# (org, repo, workflow_filename) per package.
64+
# Build all packages and produce tarballs for deferred npm publish.
65+
# Maven publishing happens here; npm publishing is deferred to
66+
# dispatch_npm_publish below.
7067
build_npm_package:
68+
runs-on: 8-core-ubuntu
7169
needs:
7270
[
7371
set_release_type,
7472
build_android,
7573
prebuild_apple_dependencies,
7674
prebuild_react_native_core,
7775
]
78-
# The top-level `permissions: contents: read` is the ceiling for
79-
# GITHUB_TOKEN in every job here, including reusable-workflow calls.
80-
# Re-grant `id-token: write` at the job level so publish-npm.yml's
81-
# `publish-react-native` job can mint the OIDC token that npm
82-
# Trusted Publishing exchanges for a publish token.
76+
container:
77+
image: reactnativecommunity/react-native-android:latest
78+
env:
79+
TERM: "dumb"
80+
GRADLE_OPTS: "-Dorg.gradle.daemon=false"
81+
LC_ALL: C.UTF8
82+
ORG_GRADLE_PROJECT_reactNativeArchitectures: "arm64-v8a"
83+
REACT_NATIVE_DOWNLOADS_DIR: /opt/react-native-downloads
84+
env:
85+
ORG_GRADLE_PROJECT_SIGNING_PWD: ${{ secrets.ORG_GRADLE_PROJECT_SIGNING_PWD }}
86+
ORG_GRADLE_PROJECT_SIGNING_KEY: ${{ secrets.ORG_GRADLE_PROJECT_SIGNING_KEY }}
87+
ORG_GRADLE_PROJECT_SONATYPE_USERNAME: ${{ secrets.ORG_GRADLE_PROJECT_SONATYPE_USERNAME }}
88+
ORG_GRADLE_PROJECT_SONATYPE_PASSWORD: ${{ secrets.ORG_GRADLE_PROJECT_SONATYPE_PASSWORD }}
89+
steps:
90+
- name: Checkout
91+
uses: actions/checkout@v6
92+
- name: Build and Pack NPM Package
93+
uses: ./.github/actions/build-npm-package
94+
with:
95+
release-type: ${{ needs.set_release_type.outputs.RELEASE_TYPE }}
96+
gradle-cache-encryption-key: ${{ secrets.GRADLE_CACHE_ENCRYPTION_KEY }}
97+
pack-only: 'true'
98+
99+
# Dispatch the centralised publish-npm.yml workflow so it runs as the
100+
# top-level workflow — making its OIDC `workflow_ref` claim match the
101+
# Trusted Publisher entry configured on npmjs.com.
102+
dispatch_npm_publish:
103+
runs-on: ubuntu-latest
104+
needs: [build_npm_package]
83105
permissions:
84-
contents: read
85-
id-token: write
86-
uses: ./.github/workflows/publish-npm.yml
87-
secrets: inherit
88-
with:
89-
mode: react-native
90-
release-type: ${{ needs.set_release_type.outputs.RELEASE_TYPE }}
106+
actions: write
107+
steps:
108+
- name: Dispatch publish-npm.yml
109+
uses: actions/github-script@v8
110+
with:
111+
script: |
112+
await github.rest.actions.createWorkflowDispatch({
113+
owner: context.repo.owner,
114+
repo: context.repo.repo,
115+
workflow_id: 'publish-npm.yml',
116+
ref: 'main',
117+
inputs: {
118+
'source-run-id': String(context.runId),
119+
},
120+
});
121+
console.log(`Dispatched publish-npm.yml with source-run-id=${context.runId}`);

.github/workflows/publish-bumped-packages.yml

Lines changed: 63 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,67 @@ on:
77
- "*-stable"
88

99
jobs:
10-
# Delegate to the shared reusable workflow so every `npm publish` in
11-
# this repo originates from one workflow file — required because npm
12-
# Trusted Publishing only accepts one (org, repo, workflow_filename)
13-
# per package.
14-
publish_bumped_packages:
10+
build_bumped_packages:
11+
runs-on: ubuntu-latest
1512
if: github.repository == 'react/react-native'
16-
uses: ./.github/workflows/publish-npm.yml
17-
secrets: inherit
18-
with:
19-
mode: monorepo-packages
13+
steps:
14+
- name: Checkout
15+
uses: actions/checkout@v6
16+
- name: Setup node.js
17+
uses: ./.github/actions/setup-node
18+
- name: Run Yarn Install
19+
uses: ./.github/actions/yarn-install
20+
- name: Build packages
21+
run: yarn build
22+
- name: Build types
23+
run: yarn build-types --skip-snapshot
24+
- name: Pack bumped packages
25+
run: node ./scripts/releases-ci/publish-updated-packages.js --pack-only
26+
- name: Upload tarballs
27+
# Only upload if the pack step produced tarballs (i.e. the commit
28+
# message contained #publish-packages-to-npm and bumped packages
29+
# were found).
30+
if: ${{ hashFiles('npm-tarballs/manifest.json') != '' }}
31+
uses: actions/upload-artifact@v6
32+
with:
33+
name: npm-tarballs
34+
path: npm-tarballs
35+
36+
# Dispatch the centralised publish-npm.yml workflow so it runs as the
37+
# top-level workflow — making its OIDC `workflow_ref` claim match the
38+
# Trusted Publisher entry configured on npmjs.com.
39+
dispatch_npm_publish:
40+
runs-on: ubuntu-latest
41+
needs: [build_bumped_packages]
42+
# Only dispatch if tarballs were produced
43+
if: ${{ needs.build_bumped_packages.result == 'success' }}
44+
permissions:
45+
actions: write
46+
steps:
47+
- name: Check for tarballs artifact
48+
id: check
49+
uses: actions/github-script@v8
50+
with:
51+
script: |
52+
const artifacts = await github.rest.actions.listWorkflowRunArtifacts({
53+
owner: context.repo.owner,
54+
repo: context.repo.repo,
55+
run_id: context.runId,
56+
});
57+
const found = artifacts.data.artifacts.some(a => a.name === 'npm-tarballs');
58+
core.setOutput('has-tarballs', String(found));
59+
- name: Dispatch publish-npm.yml
60+
if: steps.check.outputs.has-tarballs == 'true'
61+
uses: actions/github-script@v8
62+
with:
63+
script: |
64+
await github.rest.actions.createWorkflowDispatch({
65+
owner: context.repo.owner,
66+
repo: context.repo.repo,
67+
workflow_id: 'publish-npm.yml',
68+
ref: 'main',
69+
inputs: {
70+
'source-run-id': String(context.runId),
71+
},
72+
});
73+
console.log(`Dispatched publish-npm.yml with source-run-id=${context.runId}`);

.github/workflows/publish-npm.yml

Lines changed: 39 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -1,136 +1,58 @@
1-
# Reusable workflow that performs every `npm publish` in this repo.
1+
# Centralised workflow that performs every `npm publish` in this repo.
22
#
33
# Why this exists: npmjs.com Trusted Publishing accepts only ONE
4-
# (org, repo, workflow_filename, environment) tuple per package. If
5-
# `react-native` were published from `publish-release.yml` AND
6-
# `nightly.yml` directly, we'd need two Trusted Publisher entries per
7-
# package — npm rejects that. By moving every `npm publish` into this
8-
# single reusable workflow file, the OIDC `job_workflow_ref` claim
9-
# always resolves to `publish-npm.yml` regardless of which top-level
10-
# workflow triggered the run, so each package needs exactly one
11-
# Trusted Publisher entry pointing here.
4+
# (org, repo, workflow_filename, environment) tuple per package.
5+
# npm matches the `workflow_ref` OIDC claim — which is always the
6+
# TOP-LEVEL workflow, not a reusable child. Using `workflow_dispatch`
7+
# makes this file the top-level workflow, so the OIDC claim is always
8+
# `publish-npm.yml` regardless of which workflow dispatched the run.
9+
#
10+
# Callers (publish-release.yml, nightly.yml, publish-bumped-packages.yml)
11+
# build and `npm pack` their packages, upload the tarballs as a
12+
# `npm-tarballs` artifact, then dispatch this workflow to publish them.
1213
#
1314
# See https://docs.npmjs.com/trusted-publishers and
14-
# https://docs.github.com/en/actions/sharing-automations/reusing-workflows .
15-
name: Publish to npm (reusable)
15+
# https://docs.github.com/en/actions/security-for-github-actions/security-hardening-your-deployments/about-security-hardening-with-openid-connect .
16+
name: Publish to npm
1617

1718
on:
18-
workflow_call:
19+
workflow_dispatch:
1920
inputs:
20-
mode:
21-
description: |
22-
'react-native' runs the full Android/iOS-prebuilt + JS build
23-
and publishes via scripts/releases-ci/publish-npm.js (which
24-
publishes `react-native` and, in nightly mode, every
25-
@react-native/* package). 'monorepo-packages' runs only the
26-
JS build and publishes via
27-
scripts/releases-ci/publish-updated-packages.js (delta-based,
28-
gated on a #publish-packages-to-npm commit message).
29-
type: string
21+
source-run-id:
22+
description: "Workflow run ID that produced the npm-tarballs artifact."
3023
required: true
31-
release-type:
32-
description: "For mode=react-native: release | nightly | dry-run."
3324
type: string
34-
required: false
35-
default: "dry-run"
36-
skip-apple-prebuilts:
37-
description: "For mode=react-native: skip downloading prebuilt Apple artifacts."
38-
type: boolean
39-
required: false
40-
default: false
4125

42-
jobs:
43-
publish-react-native:
44-
if: inputs.mode == 'react-native'
45-
runs-on: ubuntu-latest
46-
environment: npm-publish
47-
# `id-token: write` is required so the npm CLI can mint the OIDC
48-
# token that npm Trusted Publishing exchanges for a publish token.
49-
permissions:
50-
contents: read
51-
id-token: write
52-
container:
53-
image: reactnativecommunity/react-native-android:latest
54-
env:
55-
TERM: "dumb"
56-
# Set the encoding to resolve a known character encoding issue with decompressing tar.gz files in containers
57-
# via Gradle: https://github.com/gradle/gradle/issues/23391#issuecomment-1878979127
58-
LC_ALL: C.UTF8
59-
GRADLE_OPTS: "-Dorg.gradle.daemon=false"
60-
# By default we only build ARM64 to save time/resources. For release/nightlies, we override this value to build all archs.
61-
ORG_GRADLE_PROJECT_reactNativeArchitectures: "arm64-v8a"
62-
REACT_NATIVE_DOWNLOADS_DIR: /opt/react-native-downloads
63-
env:
64-
ORG_GRADLE_PROJECT_SIGNING_PWD: ${{ secrets.ORG_GRADLE_PROJECT_SIGNING_PWD }}
65-
ORG_GRADLE_PROJECT_SIGNING_KEY: ${{ secrets.ORG_GRADLE_PROJECT_SIGNING_KEY }}
66-
ORG_GRADLE_PROJECT_SONATYPE_USERNAME: ${{ secrets.ORG_GRADLE_PROJECT_SONATYPE_USERNAME }}
67-
ORG_GRADLE_PROJECT_SONATYPE_PASSWORD: ${{ secrets.ORG_GRADLE_PROJECT_SONATYPE_PASSWORD }}
68-
steps:
69-
- name: Checkout
70-
uses: actions/checkout@v6
71-
with:
72-
fetch-depth: 0
73-
fetch-tags: true
74-
# TEMPORARY DEBUG: print the OIDC token claims npm Trusted Publishing
75-
# matches against. A 404 from the OIDC exchange means these claims don't
76-
# match the Trusted Publisher entry configured on npmjs.com (org/repo/
77-
# workflow filename / environment). Prints only the decoded claims, never
78-
# the raw token. Remove once the 404 is resolved.
79-
- name: Debug OIDC token claims
80-
shell: bash
81-
run: |
82-
# ACTIONS_ID_TOKEN_REQUEST_TOKEN/_URL are auto-injected when the job
83-
# has `id-token: write` - they are NOT secrets, don't map them in env.
84-
OIDC_TOKEN=$(curl -sS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
85-
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=npm:registry.npmjs.org" | jq -r '.value')
86-
# Decode the JWT payload (middle segment); convert base64url -> base64
87-
# and pad so `base64 -d` accepts it. Prints claims only, not the token.
88-
payload=$(echo "$OIDC_TOKEN" | cut -d'.' -f2 | tr '_-' '/+')
89-
case $(( ${#payload} % 4 )) in 2) payload+='==';; 3) payload+='=';; esac
90-
echo "$payload" | base64 -d 2>/dev/null | jq .
91-
- name: Build and Publish NPM Package
92-
uses: ./.github/actions/build-npm-package
93-
with:
94-
release-type: ${{ inputs.release-type }}
95-
gradle-cache-encryption-key: ${{ secrets.GRADLE_CACHE_ENCRYPTION_KEY }}
96-
skip-apple-prebuilts: ${{ inputs.skip-apple-prebuilts && 'true' || 'false' }}
26+
permissions:
27+
id-token: write # OIDC token for npm Trusted Publishing
28+
contents: read
29+
actions: read # download artifacts from the source workflow run
9730

98-
publish-monorepo-packages:
99-
if: inputs.mode == 'monorepo-packages'
31+
jobs:
32+
publish:
10033
runs-on: ubuntu-latest
34+
if: github.repository == 'react/react-native'
10135
environment: npm-publish
102-
permissions:
103-
contents: read
104-
id-token: write
10536
steps:
10637
- name: Checkout
10738
uses: actions/checkout@v6
10839
- name: Setup node.js
109-
uses: ./.github/actions/setup-node
40+
uses: actions/setup-node@v6
11041
with:
42+
node-version: "22.14.0"
11143
registry-url: "https://registry.npmjs.org"
112-
# TEMPORARY DEBUG: print the OIDC token claims npm Trusted Publishing
113-
# matches against. A 404 from the OIDC exchange means these claims don't
114-
# match the Trusted Publisher entry configured on npmjs.com (org/repo/
115-
# workflow filename / environment). Prints only the decoded claims, never
116-
# the raw token. Remove once the 404 is resolved.
117-
- name: Debug OIDC token claims
118-
shell: bash
119-
run: |
120-
# ACTIONS_ID_TOKEN_REQUEST_TOKEN/_URL are auto-injected when the job
121-
# has `id-token: write` - they are NOT secrets, don't map them in env.
122-
OIDC_TOKEN=$(curl -sS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
123-
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=npm:registry.npmjs.org" | jq -r '.value')
124-
# Decode the JWT payload (middle segment); convert base64url -> base64
125-
# and pad so `base64 -d` accepts it. Prints claims only, not the token.
126-
payload=$(echo "$OIDC_TOKEN" | cut -d'.' -f2 | tr '_-' '/+')
127-
case $(( ${#payload} % 4 )) in 2) payload+='==';; 3) payload+='=';; esac
128-
echo "$payload" | base64 -d 2>/dev/null | jq .
129-
- name: Run Yarn Install
130-
uses: ./.github/actions/yarn-install
131-
- name: Build packages
132-
run: yarn build
133-
- name: Build types
134-
run: yarn build-types --skip-snapshot
135-
- name: Find and publish all bumped packages
136-
run: node ./scripts/releases-ci/publish-updated-packages.js
44+
- name: Download tarballs from source run
45+
uses: actions/download-artifact@v4
46+
with:
47+
name: npm-tarballs
48+
path: ./npm-tarballs
49+
run-id: ${{ inputs.source-run-id }}
50+
github-token: ${{ secrets.GITHUB_TOKEN }}
51+
- name: Publish packages
52+
run: node ./scripts/releases-ci/publish-from-tarballs.js ./npm-tarballs
53+
- name: Upload npm logs
54+
if: always()
55+
uses: actions/upload-artifact@v6
56+
with:
57+
name: npm-publish-logs
58+
path: ~/.npm/_logs

0 commit comments

Comments
 (0)