diff --git a/.buildkite/jobs/pipeline.publish.yml b/.buildkite/jobs/pipeline.publish.yml
deleted file mode 100644
index 701b55febad..00000000000
--- a/.buildkite/jobs/pipeline.publish.yml
+++ /dev/null
@@ -1,16 +0,0 @@
- - wait
- - label: ":package: Publish"
- env:
- JAVA_HOME: /opt/openjdk/jdk-17.0.9.jdk/Contents/Home/
- if: "build.pull_request.id == null"
- command:
- - "nvm install"
- - "./scripts/ci.sh"
- - "yarn run release"
- timeout_in_minutes: 60
- retry:
- automatic:
- - exit_status: [1, -1]
- limit: 2
-
-
diff --git a/.buildkite/jobs/pipeline.release.yml b/.buildkite/jobs/pipeline.release.yml
deleted file mode 100644
index ce8978d8fdb..00000000000
--- a/.buildkite/jobs/pipeline.release.yml
+++ /dev/null
@@ -1,20 +0,0 @@
- - block: ":rocket: Release!"
- prompt: "Fill out the details for release"
- if: 'build.message =~ /^release\$/i'
- fields:
- - text: "VERSION"
- key: "version"
- - text: "NPM_TAG"
- key: "npm-tag"
- default: 'null'
- required: false
- - text: "BUILD_DOCUMENTATION_VERSION"
- key: "build-documentation-version"
- default: 'null'
- hint: 'Leave NULL if no version is specified'
- - text: "REMOVE_DOCUMENTATION_VERSION"
- key: "remove-documentation-version"
- default: 'null'
- hint: 'Leave NULL if no version is specified'
-
-
diff --git a/.buildkite/pipeline.sh b/.buildkite/pipeline.sh
index fb5185575f7..10b5c4872aa 100755
--- a/.buildkite/pipeline.sh
+++ b/.buildkite/pipeline.sh
@@ -2,11 +2,9 @@
echo "steps:"
-cat .buildkite/jobs/pipeline.release.yml
cat .buildkite/jobs/pipeline.android_rn_78.yml
cat .buildkite/jobs/pipeline.android_rn_84.yml
cat .buildkite/jobs/pipeline.android_rn_85.yml
cat .buildkite/jobs/pipeline.ios_rn_78.yml
cat .buildkite/jobs/pipeline.ios_rn_84.yml
cat .buildkite/jobs/pipeline.ios_rn_85.yml
-cat .buildkite/jobs/pipeline.publish.yml
diff --git a/.claude/skills/rn-version-upgrade.md b/.claude/skills/rn-version-upgrade.md
index 77521d86695..74b1616c369 100644
--- a/.claude/skills/rn-version-upgrade.md
+++ b/.claude/skills/rn-version-upgrade.md
@@ -63,7 +63,7 @@ Also check the full peer dependency tree: `npm info react-native@ peerD
## Phase 2: CI Infrastructure Setup
-### 2a. Create Buildkite Pipeline Jobs
+### 2a. Maintain Buildkite CI Jobs
Copy an existing job file and update the version:
@@ -88,7 +88,7 @@ cat .buildkite/jobs/pipeline.android_rn_84.yml
cat .buildkite/jobs/pipeline.ios_rn_84.yml
```
-Insert them in version order, before `pipeline.publish.yml`.
+Insert them in version order alongside the other platform jobs. Release publishing is dispatched separately through the GitHub Actions release workflow.
### 2c. Optionally Drop Old Versions
diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml
index c3a61c142f3..a5bdcfb287a 100644
--- a/.github/workflows/documentation.yml
+++ b/.github/workflows/documentation.yml
@@ -22,7 +22,8 @@ jobs:
run: |
cd website/
npm i -g npq@3.14.0
- npq install
+ npq --dry-run
+ npm ci --legacy-peer-deps
npm run build
gh-release:
if: github.event_name != 'pull_request'
@@ -54,6 +55,7 @@ jobs:
git config --global user.name "yogevbd"
cd website/
npm i -g npq@3.14.0
- npq install
+ npq --dry-run
+ npm ci --legacy-peer-deps
npm run build
npx docusaurus deploy
diff --git a/.github/workflows/release-checks.yml b/.github/workflows/release-checks.yml
new file mode 100644
index 00000000000..b23669ac365
--- /dev/null
+++ b/.github/workflows/release-checks.yml
@@ -0,0 +1,34 @@
+name: Release checks
+
+on:
+ pull_request:
+ branches: [master]
+ push:
+ branches: [master]
+
+permissions:
+ contents: read
+
+jobs:
+ package:
+ runs-on: ubuntu-24.04
+ timeout-minutes: 30
+ steps:
+ - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8
+ with:
+ persist-credentials: false
+ - uses: actions/setup-node@65d868f8d4d85d7d4abb7de0875cde3fcc8798f5
+ with:
+ node-version: '24.19.0'
+ package-manager-cache: false
+ - name: Test release authorization and recovery
+ run: node --test scripts/release/*.test.cjs
+ - name: Install, build, and test the npm package on Linux
+ run: |
+ node .yarn/releases/yarn-4.12.0.cjs install --immutable
+ node .yarn/releases/yarn-4.12.0.cjs prepare
+ node .yarn/releases/yarn-4.12.0.cjs test-js
+ - name: Verify package contents and entry points
+ env:
+ RELEASE_DIR: ${{ runner.temp }}/rnn-package
+ run: node scripts/release/index.cjs pack-check
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 00000000000..6b49c713d16
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,218 @@
+name: Release
+run-name: ${{ inputs.operation }} RNN ${{ inputs.version || inputs.kind }} by @${{ github.actor }}
+
+on:
+ workflow_dispatch:
+ inputs:
+ operation:
+ description: 'Dry run, publish, or resume an interrupted publishing run'
+ type: choice
+ options: [dry-run, publish, resume]
+ default: dry-run
+ kind:
+ description: 'Release or on-demand snapshot (ignored for resume)'
+ type: choice
+ options: [release, snapshot]
+ default: release
+ version:
+ description: 'Exact semver for release; leave empty for snapshots and resume'
+ type: string
+ npm_tag:
+ description: 'latest for stable, next for prerelease, snapshot for snapshots'
+ type: choice
+ options: [latest, next, snapshot]
+ default: latest
+ documentation_version:
+ description: 'Optional documentation version to create (x.y.z)'
+ type: string
+ remove_documentation_version:
+ description: 'Optional existing documentation version to replace/remove'
+ type: string
+ source_run_id:
+ description: 'Original publish run ID, required only for resume'
+ type: string
+
+permissions: {}
+
+concurrency:
+ group: rnn-npm-release
+ cancel-in-progress: false
+
+env:
+ RELEASE_OPERATION: ${{ inputs.operation }}
+ RELEASE_KIND: ${{ inputs.kind }}
+ RELEASE_VERSION: ${{ inputs.version }}
+ RELEASE_NPM_TAG: ${{ inputs.npm_tag }}
+ RELEASE_DOCUMENTATION_VERSION: ${{ inputs.documentation_version }}
+ RELEASE_REMOVE_DOCUMENTATION_VERSION: ${{ inputs.remove_documentation_version }}
+ RELEASE_SOURCE_RUN_ID: ${{ inputs.source_run_id }}
+ RELEASE_PUBLISH_ENABLED: ${{ vars.RELEASE_PUBLISH_ENABLED }}
+ RELEASE_DIR: ${{ github.workspace }}/../release-artifacts
+ GH_TOKEN: ${{ github.token }}
+
+jobs:
+ prepare:
+ if: github.repository == 'wix/react-native-navigation' && github.ref == 'refs/heads/master'
+ runs-on: ubuntu-24.04
+ timeout-minutes: 45
+ permissions:
+ contents: read
+ actions: read
+ statuses: read
+ pull-requests: read
+ outputs:
+ artifact_id: ${{ steps.upload.outputs.artifact-id || steps.resume.outputs.artifact_id }}
+ source_run_id: ${{ steps.resume.outputs.source_run_id || github.run_id }}
+ steps:
+ - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8
+ with:
+ ref: ${{ github.sha }}
+ fetch-depth: 0
+ persist-credentials: false
+ - uses: actions/setup-node@65d868f8d4d85d7d4abb7de0875cde3fcc8798f5
+ with:
+ node-version: '24.19.0'
+ package-manager-cache: false
+ - name: Check admin authorization
+ run: node scripts/release/index.cjs authorize
+ - name: Locate original release artifact
+ if: inputs.operation == 'resume'
+ id: resume
+ run: node scripts/release/index.cjs resume-info
+ - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
+ if: inputs.operation == 'resume'
+ with:
+ artifact-ids: ${{ steps.resume.outputs.artifact_id }}
+ run-id: ${{ steps.resume.outputs.source_run_id }}
+ github-token: ${{ github.token }}
+ path: ${{ env.RELEASE_DIR }}
+ merge-multiple: true
+ - name: Verify recovery identity and native CI
+ if: inputs.operation == 'resume'
+ run: node scripts/release/index.cjs verify-resume
+ - name: Plan exact version and check native CI
+ if: inputs.operation != 'resume'
+ id: plan
+ run: node scripts/release/index.cjs plan
+ - name: Install locked dependencies
+ if: inputs.operation != 'resume'
+ run: node .yarn/releases/yarn-4.12.0.cjs install --immutable
+ - name: Install locked documentation dependencies
+ if: inputs.operation != 'resume' && steps.plan.outputs.documentation_version != ''
+ working-directory: website
+ run: npm ci --legacy-peer-deps
+ - name: Set version and generate optional documentation
+ if: inputs.operation != 'resume'
+ run: node scripts/release/index.cjs set-version
+ - name: Build and test
+ if: inputs.operation != 'resume'
+ run: |
+ node .yarn/releases/yarn-4.12.0.cjs prepare
+ node .yarn/releases/yarn-4.12.0.cjs test-js
+ node --test scripts/release/*.test.cjs
+ - name: Validate versioned documentation
+ if: inputs.operation != 'resume' && steps.plan.outputs.documentation_version != ''
+ working-directory: website
+ run: npm run build
+ - name: Pack and record immutable release inputs
+ if: inputs.operation != 'resume'
+ run: node scripts/release/index.cjs prepare-artifact
+ - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
+ if: inputs.operation != 'resume'
+ id: upload
+ with:
+ name: release-${{ github.run_id }}-${{ github.run_attempt }}
+ path: ${{ env.RELEASE_DIR }}/
+ if-no-files-found: error
+ retention-days: 90
+
+ record:
+ needs: prepare
+ if: inputs.operation != 'dry-run'
+ environment: npm-release
+ runs-on: ubuntu-24.04
+ timeout-minutes: 10
+ permissions:
+ contents: write
+ actions: read
+ statuses: read
+ steps:
+ - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8
+ with:
+ ref: ${{ github.sha }}
+ fetch-depth: 0
+ persist-credentials: false
+ - uses: actions/setup-node@65d868f8d4d85d7d4abb7de0875cde3fcc8798f5
+ with:
+ node-version: '24.19.0'
+ package-manager-cache: false
+ - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
+ with:
+ artifact-ids: ${{ needs.prepare.outputs.artifact_id }}
+ run-id: ${{ needs.prepare.outputs.source_run_id }}
+ github-token: ${{ github.token }}
+ path: ${{ env.RELEASE_DIR }}
+ merge-multiple: true
+ - name: Persist the release commit
+ run: node scripts/release/index.cjs record
+
+ publish:
+ needs: [prepare, record]
+ environment: npm-release
+ runs-on: ubuntu-24.04
+ timeout-minutes: 10
+ permissions:
+ contents: read
+ actions: read
+ statuses: read
+ id-token: write
+ steps:
+ - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8
+ with:
+ ref: ${{ github.sha }}
+ fetch-depth: 0
+ persist-credentials: false
+ - uses: actions/setup-node@65d868f8d4d85d7d4abb7de0875cde3fcc8798f5
+ with:
+ node-version: '24.19.0'
+ package-manager-cache: false
+ - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
+ with:
+ artifact-ids: ${{ needs.prepare.outputs.artifact_id }}
+ run-id: ${{ needs.prepare.outputs.source_run_id }}
+ github-token: ${{ github.token }}
+ path: ${{ env.RELEASE_DIR }}
+ merge-multiple: true
+ - name: Publish verified tarball using npm trusted publishing
+ run: node scripts/release/index.cjs publish
+
+ finalize:
+ needs: [prepare, publish]
+ environment: npm-release
+ runs-on: ubuntu-24.04
+ timeout-minutes: 10
+ permissions:
+ contents: write
+ actions: read
+ statuses: read
+ pull-requests: write
+ issues: write
+ steps:
+ - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8
+ with:
+ ref: ${{ github.sha }}
+ fetch-depth: 0
+ persist-credentials: false
+ - uses: actions/setup-node@65d868f8d4d85d7d4abb7de0875cde3fcc8798f5
+ with:
+ node-version: '24.19.0'
+ package-manager-cache: false
+ - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
+ with:
+ artifact-ids: ${{ needs.prepare.outputs.artifact_id }}
+ run-id: ${{ needs.prepare.outputs.source_run_id }}
+ github-token: ${{ github.token }}
+ path: ${{ env.RELEASE_DIR }}
+ merge-multiple: true
+ - name: Verify npm publication and finish GitHub release
+ run: node scripts/release/index.cjs finalize
diff --git a/.gitignore b/.gitignore
index ca5349d92b7..a5c1f31219a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -233,7 +233,6 @@ playground/android/app/.settings/
AndroidE2E/.settings/
website/yarn.lock
playground/ios/Podfile.lock
-website/package-lock.json
# detox artifacts
artifacts/
diff --git a/.grenrc.js b/.grenrc.js
deleted file mode 100644
index aa0ddebdf8a..00000000000
--- a/.grenrc.js
+++ /dev/null
@@ -1,67 +0,0 @@
-module.exports = {
- template: {
- commit: ({message, url, author, name}) =>
- `- [${message}](${url}) - ${author ? `@${author}` : name}`,
- issue: ({name, labels, text, url, user_login, user_url}) => `${processLabels(labels)}${name} [${text}](${url}) by [${user_login}](${user_url})`,
- label: '[**{{label}}**]',
- noLabel: 'closed',
- group: '\n## {{heading}}\n',
- changelogTitle: '# Changelog\n\n',
- release: '## {{release}} ({{date}})\n{{body}}',
- releaseSeparator: '\n---\n\n',
- },
- groupBy: {
- 'Enhancements:': ['type: accepted/enhancement', 'internal'],
- 'Fixed:': ['type: accepted/bug'],
- Features: ['feature'],
- },
- groupPostProcessor: (groupContent) => {
- const lines = groupContent.split('\n');
- const iosIssues = [];
- const androidIssues = [];
- const otherIssues = [];
-
- for (let i = 0; i < lines.length; i++) {
- const line = lines[i];
- if (line.includes('## ') || line === '') continue;
- else if (line.includes('[iOS] ')) iosIssues.push(line.replace('[iOS] ', ''));
- else if (line.includes('[Android] ')) androidIssues.push(line.replace('[Android] ', ''));
- else otherIssues.push(line);
- }
-
- const groupHeader = groupContent.substr(0, groupContent.indexOf(':\n'));
- return `${groupHeader}${generateSection(undefined, otherIssues)}${generateSection('iOS', iosIssues)}${generateSection('Android', androidIssues)}`;
- },
- ignoreIssuesWith: ['skip-changelog'],
- ignoreTagsWith: ['snapshot', 'v1', 'v2', '0\..\..', '1\..\..', '2\..\..', '3\..\..', '4\..\..', '5\..\..', '6\..\..'],
- dataSource: 'prs',
- changelogFilename: 'CHANGELOG.gren.md',
- override: true,
- generate: true,
- tags: 'all'
-};
-
-function generateSection(name, issues) {
- if (!issues.length) return '';
- let section = `\n${name ? `### ${name}\n` : ''}`;
-
- issues.forEach(issue => {
- section += `- ${issue}\n`;
- });
-
- return `${section}\n`;
-}
-
-function processLabels(labels) {
- const includesIOS = labels.includes('**platform: iOS**');
- const includesAndroid = labels.includes('**platform: Android**');
- if (includesIOS && includesAndroid) {
- return '';
- } else if (includesIOS) {
- return '[iOS] '
- } else if (includesAndroid) {
- return '[Android] '
- }
-
- return '';
-}
diff --git a/.npmignore b/.npmignore
index 8c5a69fa9f5..21f6c9e1240 100644
--- a/.npmignore
+++ b/.npmignore
@@ -260,6 +260,5 @@ jsconfig.json
babel.config.js
.watchmanconfig
.logo.png
-.grenrc.js
.clang-format
.prettierrc
diff --git a/README.md b/README.md
index 04fbb42b1c6..eeb958b30da 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@
-
+
@@ -35,3 +35,8 @@ Apps using React Native Navigation may target iOS 11 and Android 5.0 (API 21). Y
# Installation
As `react-native-navigation` is a native navigation library - integrating it into your app will require editing native files. Follow the installation guides in the [documentation](https://wix.github.io/react-native-navigation/).
+
+## Releases
+
+Repository administrators: see the [release runbook](docs/RELEASING.md) for manual releases,
+npm trusted publishing setup, and recovery.
diff --git a/docs/RELEASING.md b/docs/RELEASING.md
new file mode 100644
index 00000000000..4fdc6c5bf15
--- /dev/null
+++ b/docs/RELEASING.md
@@ -0,0 +1,177 @@
+# Releasing React Native Navigation
+
+The [Release workflow](https://github.com/wix/react-native-navigation/actions/workflows/release.yml)
+is the only supported npm publisher. Repository admins start it manually on `master`.
+Both regular releases and snapshots use npm trusted publishing with GitHub OIDC.
+Buildkite runs native tests; it no longer versions or publishes packages. Its expired npm
+credentials must not be renewed as a fallback.
+
+## One-time configuration
+
+A repository admin and an npm package administrator must complete these steps before
+enabling publication. Creating or merging the workflow does not configure npm trust.
+
+1. Create the GitHub environment `npm-release`. Under deployment branches and tags,
+ select only the **branch** `master`. Protect `master` and review changes to release
+ workflows and helpers. Required environment reviewers are optional; enabling them
+ introduces approvals for the record, publish, and finalize jobs.
+2. In the npm settings for `react-native-navigation`, add a GitHub Actions trusted publisher:
+
+ | Field | Value |
+ | ----------------- | ------------------------- |
+ | Organization | `wix` |
+ | Repository | `react-native-navigation` |
+ | Workflow filename | `release.yml` |
+ | Environment | `npm-release` |
+ | Allowed action | Direct `npm publish` |
+
+ Explicitly allow direct publication: a stage-only configuration cannot complete this
+ workflow. Standalone `npm dist-tag` permission is not needed. See
+ [npm trusted publishing](https://docs.npmjs.com/trusted-publishers/).
+
+3. Enable GitHub Actions for this repository. Check Wix's Actions policy permits the pinned checkout, setup-node, upload-artifact,
+ and download-artifact actions. Allow workflow-created pull requests. Ensure existing
+ repository rules permit version branches and release tags without a bypass.
+4. Check the GitHub commit status `buildkite/react-native-navigation` represents all
+ Android/iOS lanes in `.buildkite/pipeline.sh`. It must finish successfully on the
+ exact `master` commit selected by the release run. The status creator must match
+ the Buildkite app ID/login pinned in `scripts/release/rnn.cjs`. A failed, absent, or pending status
+ blocks real publication, including snapshots. A dry run reports it without publishing.
+5. Verify a dry run and inspect its artifact. Then set the repository Actions variable
+ `RELEASE_PUBLISH_ENABLED` to `true`. Without it, publish and resume fail closed.
+6. Run an intentional snapshot publication and verify the npm version, integrity,
+ provenance, and `snapshot` dist-tag. Confirm that `latest` did not change.
+7. Remove obsolete release-only npm secret injection and restrict traditional token
+ publishing in npm's package settings. Do not renew expired tokens. A shared credential
+ must not be revoked until its other consumers have been identified.
+
+No npm write token is needed in GitHub. `GITHUB_TOKEN` performs repository bookkeeping;
+only the publish job has `id-token: write`. Publishing runs on a GitHub-hosted Ubuntu
+runner. The workflow uses Node 24.19.0, including npm 11.17.0; update this tested pair
+together. Older npm releases can execute `prepare` during `npm pack --ignore-scripts`.
+
+## Start a release
+
+Open **Actions → Release → Run workflow**, select `master`, and supply:
+
+| Input | Meaning |
+| ------------------------------ | ------------------------------------------------------------------------------ |
+| `operation` | `dry-run` (default), `publish`, or `resume` |
+| `kind` | `release` or `snapshot` |
+| `version` | Exact semver for releases; empty for snapshots |
+| `npm_tag` | `latest` for stable versions, `next` for prereleases, `snapshot` for snapshots |
+| `documentation_version` | Optional numeric `x.y.z` documentation snapshot |
+| `remove_documentation_version` | Optional existing version to remove while generating its replacement |
+| `source_run_id` | Original publishing run ID, for resume only |
+
+Examples:
+
+- Stable: `kind=release`, `version=8.9.1`, `npm_tag=latest`.
+- Prerelease: `kind=release`, `version=8.9.0-rc.1`, `npm_tag=next`.
+- Snapshot: `kind=snapshot`, leave version empty, `npm_tag=snapshot`.
+
+The selected version must be newer than npm `latest`. Build metadata such as `+build.1`
+is intentionally unsupported. Snapshots use `-snapshot.gha.`, with the
+base derived from the greater of the source package version and npm `latest`. The
+run attempt is not part of the version. Snapshots do not create tags, GitHub releases,
+version PRs, or documentation changes.
+
+Users with write access may see GitHub's Run workflow button, but both the original
+actor and the user rerunning/resuming the workflow must have repository-admin permission.
+The workflow rechecks authorization before every job that changes external state.
+
+## What the workflow does
+
+1. Records the exact source SHA, chosen version/channel, and native CI result.
+2. Installs the locked Yarn dependencies, changes release metadata, builds JS and
+ declarations with Bob, and runs JavaScript and release-helper tests on Linux.
+3. Optionally installs the locked website dependencies, generates the requested docs
+ version, and builds the site. Removal requires an existing numeric version; replacing
+ an existing version requires explicitly selecting that same version for removal.
+4. Creates a local release commit containing only the version and optional versioned
+ docs, then packs the already built package. Package contents and exported paths are
+ checked. Android/iOS sources ship in the package; this job does not compile native apps.
+5. Uploads a versioned manifest, the tarball, release notes, and a Git bundle as
+ `release--`. A dry run ends here with no external release mutations.
+6. For real releases, persists `ci/update-version-`, then publishes the verified
+ tarball using OIDC. The publisher does not install project dependencies or rebuild it.
+7. Verifies npm integrity, tags the source commit on `master` with the bare version (for example
+ `8.9.1`), creates a GitHub release, and opens a version/documentation PR with the
+ `release` label. An existing PR template is included with its checkboxes left for review.
+
+Release notes include merged PRs from the preceding reachable stable tag through the
+source commit. They preserve feature/enhancement/fix categories, platform groups, and
+`skip-changelog` exclusions. No second `gren` invocation or timed retry is involved.
+
+Review and merge the version PR after publication. Documentation deploys through the
+existing documentation workflow after that merge. Events created with `GITHUB_TOKEN` do not
+start Actions workflows, so Release checks will not run on the version PR; Buildkite still
+builds it through its webhook. Inspect the PR's checks before merging. The release workflow
+does not invoke engine notification.
+
+## Resume a partial release
+
+Use **Re-run failed jobs** when the original run still has its preparation outputs, or
+dispatch `operation=resume` with `source_run_id` set to the original **publish** run.
+Other release inputs are ignored in resume mode. Do not enter a different version to
+recover from a GitHub or network error.
+
+Recovery validates the upstream workflow identity, source SHA, native CI, artifact
+integrity, release commit, and package metadata. It downloads the saved artifacts and
+does not rebuild against the current branch. If the version is still absent from npm and
+`master` has advanced, re-run the original publishing run instead of dispatching a new
+resume run: npm provenance must identify the source commit that built the package. A new
+resume dispatch may finish GitHub bookkeeping for an already published matching tarball.
+
+| State | Result |
+| ------------------------------------------------- | -------------------------------------------------------------------------- |
+| Version absent from npm | Publish the original tarball, provided its channel would not move backward |
+| Same version and same integrity already published | Skip publication and finish GitHub steps |
+| Same version with different integrity | Stop; investigate the conflicting publication |
+| Registry failure or lost response | Fail without choosing another version; resume when healthy |
+| Existing branch/tag/PR matches | Reuse it |
+| Existing branch/tag/PR conflicts | Stop; never force-push or move a tag |
+| Dry-run artifact selected | Reject; start a fresh publish run |
+| Artifact expired/deleted | Stop automatic recovery |
+
+Artifacts request 90-day retention, subject to organization limits. Retain artifacts until
+the version PR is merged and the release is verified. After artifact loss, an admin must
+investigate the existing registry version and persisted release branch manually; do not
+reconstruct and publish different bytes under the same version or automatically fall back
+to the old pipeline. npm and GitHub do not provide a cross-service atomic transaction.
+
+The concurrency group serializes all release attempts. GitHub may replace a pending run
+when another dispatch is queued; it does not cancel the running publisher. Check each run's
+state rather than assuming every click is queued indefinitely.
+
+## Validate changes to the release tooling
+
+With Node 24.19.0 and npm 11.17.0:
+
+```sh
+node --test scripts/release/*.test.cjs
+node .yarn/releases/yarn-4.12.0.cjs install --immutable
+node .yarn/releases/yarn-4.12.0.cjs prepare
+node .yarn/releases/yarn-4.12.0.cjs test-js
+RELEASE_DIR=/tmp/rnn-package node scripts/release/index.cjs pack-check
+```
+
+`pack-check` only prepares and inspects a local tarball. The read-only Release checks
+workflow runs the tests and clean Linux package check on PRs. A real OIDC publication
+still needs the default-branch workflow and the npm/GitHub configuration above.
+
+## Removed release paths and retained consumers
+
+The Buildkite release form, publishing job, legacy `scripts/release.js`, `gren` config,
+and root/playground `github-release-notes` dependencies have been removed. Automatic
+Buildkite snapshots are replaced by manual snapshots. Native Buildkite testing remains.
+
+Outside the repository, remove obsolete release schedules/hooks and release-only
+`NPM_TOKEN`/`NPM_EMAIL` injection. Inspect any old Jenkins configuration referenced by
+historical setup; the old README badge alone does not prove an active publisher exists.
+
+Keep the tracked registry-only `.npmrc`, documentation deployment's `GH_PAGES_DEPLOY`,
+and any Git credential still used by `scripts/test-snapshot.js` to record reference images.
+Do not delete shared secrets merely because the old publisher also used them.
+
+For the next repository, use [the migration checklist](release-migration-checklist.md).
diff --git a/docs/release-migration-checklist.md b/docs/release-migration-checklist.md
new file mode 100644
index 00000000000..a1e35ffd5ad
--- /dev/null
+++ b/docs/release-migration-checklist.md
@@ -0,0 +1,69 @@
+# Migrating another repository to trusted publishing
+
+RNN establishes a release contract: admin dispatch, explicit versions, immutable build
+artifacts, OIDC publication, and recovery of the same package bytes. Copy the contract
+before extracting shared code. Repository-specific build and package policy lives in
+`scripts/release/rnn.cjs`; the current workflow and manifest validator intentionally accept
+only RNN. Another repository must implement and test its own adapter and authorization.
+
+## Inventory before changing a publisher
+
+- Read the target repository's instructions and release tests.
+- List every publishable package, its source directory, version authority, dist-tags,
+ Git tag convention, and dependency relationship to the other packages.
+- Trace release-trigger inputs, lifecycle hooks, generated artifacts, documentation,
+ release notes, PRs, and any native packaging commands.
+- Identify native CI evidence by exact commit. Distinguish the tools needed to produce
+ the npm package from tools needed only for testing.
+- Inventory CI schedules, webhooks, release credentials, and other consumers of shared
+ credentials. A release script deletion alone does not remove an external trigger.
+
+## Implement the repository adapter
+
+- Keep a stable repository-local `release.yml` entry point and protected environment.
+- Configure allowed branches, admin authorization, packages, runners, build commands,
+ expected tarball contents, version/dependency rewrites, and tag naming.
+- Resolve the complete package/version plan before the first publish. Use an ordered
+ package list and store each package's integrity in the manifest.
+- Build without npm publishing credentials. Native preparation can use separate runner
+ jobs; the OIDC publisher must run on a supported GitHub-hosted runner.
+- Preserve artifacts and verify their original workflow/source identity during recovery.
+- Publish each package only if absent. If already present, require matching integrity.
+ Never bump all versions after a partial publish. Multi-package publication is not atomic.
+- Register npm trust separately for every package. If introducing reusable workflows,
+ check npm's caller-workflow identity rules and grant the required OIDC permissions at
+ the caller/callee boundaries. Pin shared implementations to reviewed commit SHAs.
+
+## Known differences from RNN
+
+| Repository | Migration requirement |
+| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| react-native-navigation | One package, Bob JS/types build, native sources, bare version Git tags |
+| react-native-ui-lib | Separate `uilib-native` and `react-native-ui-lib` versions; native package first when selected; rewrite workspace dependencies; preserve package-qualified tags |
+| Detox | Discover and validate publishable workspaces; preserve synchronized versions; trace Android and iOS native artifact packaging before choosing runners |
+
+UI-lib's starting points are `scripts/release/release.js`, `releaseUtils.js`, and the
+existing release-script tests. Its dependency rewrite must use the planned or verified
+published native version, including when only one package needs publication.
+
+Detox's starting points are `scripts/ci.release.js`, `scripts/utils/publishNewVersion.js`,
+`scripts/ci.android-release.js`, `scripts/ci.ios-release.sh`, and the Buildkite release
+pipelines. Android packaging invokes the SDK and Gradle. Resolve the current iOS packaging
+entry point before replacing its artifact download flow. Do not assume RNN's Linux-only
+package preparation applies to Detox.
+
+## Clean up and verify
+
+- Delete obsolete publishing jobs, forms, entry points, credential-writing helpers,
+ retry-by-version-increment code, dead dependencies, and stale instructions.
+- Preserve native tests, useful history, documentation deployment, and shared credentials
+ with remaining consumers.
+- Test dry-run non-mutation, unauthorized dispatch/rerun, version/channel validation,
+ source CI gating, package completeness, and lost-response/partial-publication recovery.
+- Verify clean runner builds, then a deliberate prerelease/snapshot using real OIDC.
+- Document one-time configuration, remaining infrastructure cleanup, and artifact-loss
+ recovery. Expired token-based publishers are not an automatic fallback.
+
+Extract the common publisher, authorization, and artifact-state helpers only after a
+second adopter confirms their interface. Build commands, package graphs, documentation,
+and version conventions should remain repository-specific.
diff --git a/package.json b/package.json
index 5624cb564ca..3ffef8a151e 100644
--- a/package.json
+++ b/package.json
@@ -67,7 +67,7 @@
"test-e2e-android-ci": "yarn workspace react-native-navigation-playground test-e2e-android-ci",
"test-e2e-ios-ci": "yarn workspace react-native-navigation-playground test-e2e-ios-ci",
"test-all": "node ./scripts/test-all",
- "release": "node ./scripts/release",
+ "test-release": "node --test scripts/release/*.test.cjs",
"lint": "eslint --ext .js,.jsx,.ts,.tsx ./"
},
"peerDependencies": {
@@ -124,7 +124,6 @@
"eslint-formatter-codeframe": "^7.32.1",
"eslint-plugin-jest": "^28.11.0",
"eslint-plugin-prettier": "3.1.4",
- "github-release-notes": "https://github.com/yogevbd/github-release-notes/tarball/e601b3dba72dcd6cba323c1286ea6dd0c0110b58",
"identity-obj-proxy": "3.0.0",
"jest": "^29.6.3",
"lint-staged": "10.2.11",
@@ -183,4 +182,4 @@
]
]
}
-}
\ No newline at end of file
+}
diff --git a/playground/package.json b/playground/package.json
index a4dac6abbad..b923d55b724 100644
--- a/playground/package.json
+++ b/playground/package.json
@@ -62,7 +62,6 @@
"eslint-formatter-codeframe": "^7.32.1",
"eslint-plugin-jest": "^28.11.0",
"eslint-plugin-prettier": "3.1.4",
- "github-release-notes": "https://github.com/yogevbd/github-release-notes/tarball/e601b3dba72dcd6cba323c1286ea6dd0c0110b58",
"identity-obj-proxy": "3.0.0",
"jest": "^29.6.3",
"lint-staged": "10.2.11",
diff --git a/scripts/documentation.js b/scripts/documentation.js
index 231db403248..7bc423f7676 100644
--- a/scripts/documentation.js
+++ b/scripts/documentation.js
@@ -1,42 +1,70 @@
-const shellUtils = require('shell-utils');
-const exec = shellUtils.exec;
+const childProcess = require('child_process');
const fs = require('fs');
-const includes = require('lodash/includes');
+const path = require('path');
-const docsPath = `${process.cwd()}/website`;
-const docsVersionsJsonPath = `${docsPath}/versions.json`;
+const docsPath = path.resolve(__dirname, '../website');
+
+function release(version, removeVersion, options = {}) {
+ const targetDocsPath = options.docsPath || docsPath;
+ const versionsPath = path.join(targetDocsPath, 'versions.json');
+
+ _validateVersion(version, 'version');
+ if (removeVersion !== undefined && removeVersion !== '') {
+ _validateVersion(removeVersion, 'removeVersion');
+ }
+
+ const versions = _readDocsVersionsJson(versionsPath);
+ const shouldRemoveVersion = removeVersion !== undefined && removeVersion !== '';
+
+ if (shouldRemoveVersion && !versions.includes(removeVersion)) {
+ throw new Error(`Documentation version does not exist: ${removeVersion}`);
+ }
+ if (versions.includes(version) && (!shouldRemoveVersion || removeVersion !== version)) {
+ throw new Error(`Documentation version already exists: ${version}`);
+ }
+
+ if (shouldRemoveVersion) {
+ _removeDocsVersion(targetDocsPath, versionsPath, versions, removeVersion);
+ }
-function release(version, removeVersion) {
console.log(`Building documentation version: ${version}`);
- if (_versionExists(removeVersion)) _removeDocsVersion(removeVersion);
- const originalDir = process.cwd();
- process.chdir(docsPath);
- exec.execSync(`npm install`);
- exec.execSync(`npm run docusaurus docs:version ${version}`);
- exec.execSync(`git add ../website`);
- process.chdir(originalDir);
+ childProcess.execFileSync('npm', ['run', 'docusaurus', '--', 'docs:version', version], {
+ cwd: targetDocsPath,
+ stdio: 'inherit',
+ });
}
-function _removeDocsVersion(version) {
- console.log(`Removing documentation version: ${version}`);
- exec.execSync(`rm -rf ${docsPath}/versioned_docs/version-${version}`);
- exec.execSync(`rm -f ${docsPath}/versioned_sidebars/version-${version}-sidebars.json`);
- const docsVersionsJson = _readDocsVersionsJson();
- docsVersionsJson.splice(docsVersionsJson.indexOf(version), 1);
- _writeDocsVersionsJson(docsVersionsJson);
+function _validateVersion(version, name) {
+ if (typeof version !== 'string' || !/^\d+(?:\.\d+)+$/.test(version)) {
+ throw new Error(`${name} must be a dotted numeric version`);
+ }
}
-function _versionExists(version) {
- console.log(`check if version exists: ${version}`);
- return version !== '' && includes(_readDocsVersionsJson(), version);
+function _removeDocsVersion(targetDocsPath, versionsPath, versions, version) {
+ console.log(`Removing documentation version: ${version}`);
+ fs.rmSync(path.join(targetDocsPath, 'versioned_docs', `version-${version}`), {
+ recursive: true,
+ force: true,
+ });
+ fs.rmSync(path.join(targetDocsPath, 'versioned_sidebars', `version-${version}-sidebars.json`), {
+ force: true,
+ });
+ _writeDocsVersionsJson(
+ versionsPath,
+ versions.filter((existingVersion) => existingVersion !== version)
+ );
}
-function _readDocsVersionsJson() {
- return JSON.parse(fs.readFileSync(docsVersionsJsonPath));
+function _readDocsVersionsJson(versionsPath) {
+ const versions = JSON.parse(fs.readFileSync(versionsPath, 'utf8'));
+ if (!Array.isArray(versions)) {
+ throw new Error('Documentation versions must be an array');
+ }
+ return versions;
}
-function _writeDocsVersionsJson(versionsJson) {
- fs.writeFileSync(docsVersionsJsonPath, JSON.stringify(versionsJson, null, 2));
+function _writeDocsVersionsJson(versionsPath, versions) {
+ fs.writeFileSync(versionsPath, JSON.stringify(versions, null, 2));
}
module.exports = {
diff --git a/scripts/documentation.test.js b/scripts/documentation.test.js
index e861437e843..31c7f36e866 100644
--- a/scripts/documentation.test.js
+++ b/scripts/documentation.test.js
@@ -1,56 +1,122 @@
-jest.mock('shell-utils');
-const exec = require('shell-utils').exec;
-
-jest.mock('fs');
+const childProcess = require('child_process');
const fs = require('fs');
+const os = require('os');
+const path = require('path');
const documentation = require('./documentation');
describe('Documentation script', () => {
- beforeAll(() => {
- process.chdir = jest.fn();
+ let docsPath;
+ let versionsPath;
+ let execFileSync;
+
+ beforeEach(() => {
+ docsPath = fs.mkdtempSync(path.join(os.tmpdir(), 'rnn-documentation-'));
+ versionsPath = path.join(docsPath, 'versions.json');
+ fs.mkdirSync(path.join(docsPath, 'versioned_docs'), { recursive: true });
+ fs.mkdirSync(path.join(docsPath, 'versioned_sidebars'), { recursive: true });
+ fs.writeFileSync(versionsPath, JSON.stringify(['2.0.0', '1.0.0']));
+ execFileSync = jest.spyOn(childProcess, 'execFileSync').mockImplementation(() => {});
});
afterEach(() => {
- jest.resetAllMocks();
+ jest.restoreAllMocks();
+ fs.rmSync(docsPath, { recursive: true, force: true });
});
- it('should build new version', () => {
- const version = '1.0.0';
- fs.readFileSync.mockReturnValue(JSON.stringify([]));
- let initialWorkingDir = process.cwd();
- documentation.release('1.0.0');
- expect(exec.execSync).toHaveBeenCalledTimes(3);
- expect(process.chdir).toHaveBeenCalledWith(docsPath());
-
- expect(exec.execSync).toHaveBeenCalledWith(`npm install`);
- expect(exec.execSync).toHaveBeenCalledWith(`npm run docusaurus docs:version ${version}`);
- expect(exec.execSync).toHaveBeenCalledWith(`git add ../website`);
- expect(process.chdir).toHaveBeenCalledWith(process.cwd());
+ it('generates a new version without changing the caller cwd', () => {
+ const cwd = process.cwd();
+
+ documentation.release('3.0.0', undefined, { docsPath });
+
+ expect(execFileSync).toHaveBeenCalledWith(
+ 'npm',
+ ['run', 'docusaurus', '--', 'docs:version', '3.0.0'],
+ { cwd: docsPath, stdio: 'inherit' }
+ );
+ expect(process.cwd()).toBe(cwd);
+ expect(readVersions()).toEqual(['2.0.0', '1.0.0']);
});
- it('should override version', () => {
- fs.readFileSync.mockReturnValue(JSON.stringify(['1.0.0', '2.0.0']));
+ it.each(['../escape', '1.0.0; touch /tmp/pwned', '$(touch /tmp/pwned)', ''])(
+ 'rejects unsafe version %s before mutation',
+ (version) => {
+ const before = fs.readFileSync(versionsPath, 'utf8');
+ const cwd = process.cwd();
- const version = '2.0.0';
- const removeVersion = '2.0.0';
- documentation.release(version, removeVersion);
+ expect(() => documentation.release(version, undefined, { docsPath })).toThrow(
+ /dotted numeric version/
+ );
+ expect(execFileSync).not.toHaveBeenCalled();
+ expect(fs.readFileSync(versionsPath, 'utf8')).toBe(before);
+ expect(process.cwd()).toBe(cwd);
+ }
+ );
- expect(exec.execSync).toHaveBeenCalledTimes(5);
+ it('rejects removal of an unknown version before mutation', () => {
+ const before = fs.readFileSync(versionsPath, 'utf8');
+ const cwd = process.cwd();
- expect(process.chdir).toHaveBeenCalledWith(docsPath());
- expect(exec.execSync).toHaveBeenCalledWith(
- `rm -rf ${docsPath()}/versioned_docs/version-${version}`
+ expect(() => documentation.release('3.0.0', '4.0.0', { docsPath })).toThrow(
+ 'Documentation version does not exist: 4.0.0'
);
- expect(exec.execSync).toHaveBeenCalledWith(
- `rm -f ${docsPath()}/versioned_sidebars/version-${version}-sidebars.json`
+ expect(execFileSync).not.toHaveBeenCalled();
+ expect(fs.readFileSync(versionsPath, 'utf8')).toBe(before);
+ expect(process.cwd()).toBe(cwd);
+ });
+
+ it.each(['../escape', '1.0.0; touch /tmp/pwned', '$(touch /tmp/pwned)'])(
+ 'rejects unsafe removal version %s before mutation',
+ (removeVersion) => {
+ const before = fs.readFileSync(versionsPath, 'utf8');
+ const cwd = process.cwd();
+
+ expect(() => documentation.release('3.0.0', removeVersion, { docsPath })).toThrow(
+ /dotted numeric version/
+ );
+ expect(execFileSync).not.toHaveBeenCalled();
+ expect(fs.readFileSync(versionsPath, 'utf8')).toBe(before);
+ expect(process.cwd()).toBe(cwd);
+ }
+ );
+
+ it('rejects regenerating an existing version unless removing that same version', () => {
+ const before = fs.readFileSync(versionsPath, 'utf8');
+ const cwd = process.cwd();
+
+ expect(() => documentation.release('2.0.0', undefined, { docsPath })).toThrow(
+ 'Documentation version already exists: 2.0.0'
);
- expect(exec.execSync).toHaveBeenCalledWith(`npm run docusaurus docs:version ${version}`);
- expect(exec.execSync).toHaveBeenCalledWith(`git add ../website`);
- expect(process.chdir).toHaveBeenCalledWith(process.cwd());
+ expect(execFileSync).not.toHaveBeenCalled();
+ expect(fs.readFileSync(versionsPath, 'utf8')).toBe(before);
+ expect(process.cwd()).toBe(cwd);
+ });
+
+ it('removes an existing version and its generated files before regenerating it', () => {
+ const versionDocs = path.join(docsPath, 'versioned_docs', 'version-2.0.0');
+ const versionSidebar = path.join(docsPath, 'versioned_sidebars', 'version-2.0.0-sidebars.json');
+ fs.mkdirSync(versionDocs);
+ fs.writeFileSync(path.join(versionDocs, 'README.md'), 'old docs');
+ fs.writeFileSync(versionSidebar, '{}');
+
+ documentation.release('2.0.0', '2.0.0', { docsPath });
+
+ expect(fs.existsSync(versionDocs)).toBe(false);
+ expect(fs.existsSync(versionSidebar)).toBe(false);
+ expect(readVersions()).toEqual(['1.0.0']);
+ expect(execFileSync).toHaveBeenCalledTimes(1);
});
-});
-function docsPath() {
- return `${process.cwd()}/website`;
-}
+ it('removes the last existing version from the versions list', () => {
+ fs.writeFileSync(versionsPath, JSON.stringify(['1.0.0']));
+
+ documentation.release('2.0.0', '1.0.0', { docsPath });
+
+ expect(readVersions()).toEqual([]);
+ expect(execFileSync).toHaveBeenCalledTimes(1);
+ });
+
+ function readVersions() {
+ return JSON.parse(fs.readFileSync(versionsPath, 'utf8'));
+ }
+});
diff --git a/scripts/release.js b/scripts/release.js
deleted file mode 100644
index 7bb9ee6b08f..00000000000
--- a/scripts/release.js
+++ /dev/null
@@ -1,173 +0,0 @@
-/* tslint:disable: no-console */
-const exec = require('shell-utils').exec;
-const semver = require('semver');
-const fs = require('fs');
-const cp = require('child_process');
-const includes = require('lodash/includes');
-const documentation = require('./documentation');
-
-const packageJsonPath = `${process.cwd()}/package.json`;
-
-// Export buildkite variables for Release build
-// We cast toString() because 'buildkite-agent meta-data get' function returns 'object'
-const BRANCH = process.env.BUILDKITE_BRANCH;
-const isRelease = process.env.BUILDKITE_MESSAGE.match(/^release$/i);
-let VERSION, VERSION_TAG, BUILD_DOCUMENTATION_VERSION, REMOVE_DOCUMENTATION_VERSION;
-if (isRelease) {
- VERSION = cp.execSync(`buildkite-agent meta-data get version`).toString();
- VERSION_TAG = cp.execSync(`buildkite-agent meta-data get npm-tag`).toString();
- BUILD_DOCUMENTATION_VERSION = cp
- .execSync(`buildkite-agent meta-data get build-documentation-version`)
- .toString();
- REMOVE_DOCUMENTATION_VERSION = cp
- .execSync(`buildkite-agent meta-data get remove-documentation-version`)
- .toString();
-}
-
-console.log(typeof VERSION);
-// Workaround JS
-if (VERSION_TAG == 'null') {
- VERSION_TAG = isRelease ? 'latest' : 'snapshot';
-}
-const VERSION_INC = 'patch';
-
-function run() {
- if (!validateEnv()) {
- return;
- }
- setupGit();
- createNpmRc();
- versionTagAndPublish();
-}
-
-function validateEnv() {
- if (!process.env.CI) {
- throw new Error(`releasing is only available from CI`);
- }
- return true;
-}
-
-function setupGit() {
- exec.execSyncSilent(`git config --global push.default simple`);
- exec.execSyncSilent(`git config --global user.email "${process.env.GIT_EMAIL}"`);
- exec.execSyncSilent(`git config --global user.name "${process.env.GIT_USER}"`);
- const remoteUrl = new RegExp(`https?://(\\S+)`).exec(exec.execSyncRead(`git remote -v`))[1];
- exec.execSyncSilent(
- `git remote add deploy "https://${process.env.GIT_USER}:${process.env.GIT_TOKEN}@${remoteUrl}"`
- );
- // exec.execSync(`git checkout ${ONLY_ON_BRANCH}`);
-}
-
-function createNpmRc() {
- exec.execSync(`rm -f package-lock.json`);
- const content = `
-email=\${NPM_EMAIL}
-//registry.npmjs.org/:_authToken=\${NPM_TOKEN}
-`;
- fs.writeFileSync(`.npmrc`, content);
-}
-
-function versionTagAndPublish() {
- const packageVersion = semver.clean(process.env.npm_package_version);
- console.log(`package version: ${packageVersion}`);
-
- const currentPublished = findCurrentPublishedVersion();
- console.log(`current published version: ${currentPublished}`);
-
- const version = isRelease
- ? VERSION
- : semver.gt(packageVersion, currentPublished)
- ? `${packageVersion}-snapshot.${process.env.BUILDKITE_BUILD_NUMBER}`
- : `${currentPublished}-snapshot.${process.env.BUILDKITE_BUILD_NUMBER}`;
-
- console.log(`Publishing version: ${version}`);
-
- tryPublishAndTag(version);
-}
-
-function findCurrentPublishedVersion() {
- return exec.execSyncRead(`npm view ${process.env.npm_package_name} dist-tags.latest`);
-}
-
-function tryPublishAndTag(version) {
- let theCandidate = version;
- for (let retry = 0; retry < 5; retry++) {
- try {
- tagAndPublish(theCandidate);
- console.log(`Released ${theCandidate}`);
- return;
- } catch (err) {
- let alreadyPublished = false;
- try {
- cp.execSync(`npm view ${process.env.npm_package_name}@${theCandidate} version`, {stdio: 'pipe'});
- alreadyPublished = true;
- } catch (_) {
- alreadyPublished = false;
- }
- if (!alreadyPublished) {
- throw err;
- }
- console.log(`previously published. retrying with increased ${VERSION_INC}...`);
- theCandidate = semver.inc(theCandidate, VERSION_INC);
- }
- }
-}
-
-function tagAndPublish(newVersion) {
- console.log(`trying to publish ${newVersion}...`);
- if (BUILD_DOCUMENTATION_VERSION && BUILD_DOCUMENTATION_VERSION !== 'null')
- documentation.release(BUILD_DOCUMENTATION_VERSION, REMOVE_DOCUMENTATION_VERSION);
- exec.execSync(`npm --no-git-tag-version version ${newVersion}`);
- exec.execSync(`npm publish --tag ${VERSION_TAG}`);
- if (isRelease) {
- exec.execSync(`git tag -a ${newVersion} -m "${newVersion}"`);
- exec.execSyncSilent(`git push deploy ${newVersion} || true`);
- updatePackageJsonGit(newVersion);
- }
-}
-
-function writePackageJson(packageJson) {
- fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2));
-}
-
-function readPackageJson() {
- return JSON.parse(fs.readFileSync(packageJsonPath));
-}
-
-function updatePackageJsonGit(version) {
- const prBranch = `ci/update-version-${version}`;
- exec.execSync(`git checkout ${BRANCH}`);
- exec.execSync(`git checkout -b ${prBranch}`);
- const packageJson = readPackageJson();
- packageJson.version = version;
- writePackageJson(packageJson);
- exec.execSync(`git add package.json`);
- exec.execSync(`git commit -m"Update package.json version to ${version} [buildkite skip]"`);
- exec.execSync(`git push deploy ${prBranch}`);
-
- const prData = JSON.stringify({
- title: `Update package.json version to ${version}`,
- head: prBranch,
- base: BRANCH,
- body: `Automated version bump to ${version} from CI release.`,
- });
-
- cp.execSync(
- `curl -s -X POST ` +
- `-H "Authorization: token ${process.env.GIT_TOKEN}" ` +
- `-H "Content-Type: application/json" ` +
- `-d '${prData}' ` +
- `https://api.github.com/repos/wix/react-native-navigation/pulls`
- );
-
- draftGitRelease(version);
-}
-
-function draftGitRelease(version) {
- exec.execSync(`npx gren release --tags=${version}`);
- exec.execSync(`sleep 30`);
- // For some unknown reason, gren release works well only when calling it twice.
- exec.execSync(`npx gren release --tags=${version}`);
-}
-
-run();
diff --git a/scripts/release/artifact.cjs b/scripts/release/artifact.cjs
new file mode 100644
index 00000000000..532e08e974b
--- /dev/null
+++ b/scripts/release/artifact.cjs
@@ -0,0 +1,163 @@
+const fs = require('node:fs');
+const path = require('node:path');
+const { createHash } = require('node:crypto');
+const { execFileSync } = require('node:child_process');
+const adapter = require('./rnn.cjs');
+const { check, compareVersions, validateManifest } = require('./policy.cjs');
+
+function exec(command, args, options = {}) {
+ return execFileSync(command, args, { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, ...options });
+}
+
+function integrity(file) {
+ return `sha512-${createHash('sha512').update(fs.readFileSync(file)).digest('base64')}`;
+}
+
+function validatePackageFiles(files, pkg) {
+ const names = new Set(files);
+ for (const file of adapter.requiredFiles) check(names.has(file), `Package is missing ${file}`);
+ check(
+ files.some((file) => file.startsWith('ios/') && /\.(m|mm|cpp)$/.test(file)),
+ 'Package is missing iOS implementation'
+ );
+ check(
+ files.some((file) => file.startsWith('android/') && /\.(java|kt)$/.test(file)),
+ 'Package is missing Android implementation'
+ );
+ for (const file of files) {
+ check(
+ !file.startsWith('/') && !file.split('/').includes('..') && !file.includes('\\'),
+ `Unsafe package path: ${file}`
+ );
+ check(
+ !/(^|\/)(node_modules|\.npmrc|\.git|\.github|\.buildkite|\.gradle|\.yarn|DerivedData)(\/|$)/.test(
+ file
+ ),
+ `Unexpected private/build path: ${file}`
+ );
+ check(
+ !/^(scripts|playground|website|coverage|artifacts)\//.test(file),
+ `Unexpected development file: ${file}`
+ );
+ check(
+ !/\.(apk|aab|ipa|jks|keystore)$/.test(file),
+ `Unexpected native binary/credential: ${file}`
+ );
+ }
+ check(pkg.name === adapter.packageName, 'Unexpected packed package name');
+ check(
+ pkg.repository?.url === `https://github.com/${adapter.repository}.git`,
+ 'Package repository must match npm trust'
+ );
+ const entries = [pkg.main, pkg.types, pkg['react-native'], ...Object.values(pkg.bin || {})];
+ for (const value of Object.values(pkg.exports || {})) {
+ if (typeof value === 'string') entries.push(value);
+ else entries.push(...Object.values(value));
+ }
+ for (const entry of entries) {
+ check(
+ typeof entry === 'string' && names.has(entry.replace(/^\.\//, '')),
+ `Unresolvable package entry: ${entry}`
+ );
+ }
+}
+
+function pack(directory) {
+ check(
+ compareVersions(exec('npm', ['--version']).trim(), '11.17.0') >= 0,
+ 'Package preparation requires npm >=11.17.0 (Node 24.19.0); older npm can run prepare despite --ignore-scripts'
+ );
+ fs.mkdirSync(directory, { recursive: true });
+ const output = exec('npm', [
+ 'pack',
+ '--ignore-scripts',
+ '--json',
+ '--pack-destination',
+ directory,
+ ]);
+ const results = JSON.parse(output);
+ check(results.length === 1, 'Expected one RNN tarball');
+ const result = results[0];
+ const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
+ validatePackageFiles(
+ result.files.map((file) => file.path),
+ pkg
+ );
+ const actual = integrity(path.join(directory, result.filename));
+ check(actual === result.integrity, 'npm pack integrity mismatch');
+ return { name: pkg.name, version: pkg.version, filename: result.filename, integrity: actual };
+}
+
+function readManifest(directory, options) {
+ const manifest = validateManifest(
+ JSON.parse(fs.readFileSync(path.join(directory, 'manifest.json'), 'utf8')),
+ options
+ );
+ for (const pkg of manifest.packages) {
+ const tarball = path.join(directory, pkg.filename);
+ check(fs.lstatSync(tarball).isFile(), 'Package artifact must be a regular file');
+ check(integrity(tarball) === pkg.integrity, `Artifact integrity mismatch for ${pkg.name}`);
+ const names = exec('tar', ['-tzf', tarball])
+ .trim()
+ .split('\n')
+ .map((name) => {
+ check(name.startsWith('package/'), 'Unexpected tarball root');
+ return name.slice('package/'.length);
+ })
+ .filter(Boolean);
+ const packed = JSON.parse(exec('tar', ['-xOzf', tarball, 'package/package.json']));
+ validatePackageFiles(names, packed);
+ check(packed.version === pkg.version, 'Packed version differs from release manifest');
+ }
+ for (const [filename, expected] of Object.entries(manifest.files || {})) {
+ check(['release.bundle', 'notes.md'].includes(filename), 'Unexpected manifest sidecar');
+ check(
+ integrity(path.join(directory, filename)) === expected,
+ `Artifact integrity mismatch for ${filename}`
+ );
+ }
+ check(
+ manifest.files?.['release.bundle'] && manifest.files?.['notes.md'],
+ 'Missing release artifact sidecars'
+ );
+ return manifest;
+}
+
+function verifyReleaseCommit(manifest, directory) {
+ exec('git', ['bundle', 'verify', path.join(directory, 'release.bundle')], {
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ exec('git', ['fetch', '--no-tags', path.join(directory, 'release.bundle'), 'HEAD']);
+ check(
+ exec('git', ['rev-parse', 'FETCH_HEAD']).trim() === manifest.releaseSha,
+ 'Bundle does not contain the release commit'
+ );
+ check(
+ exec('git', ['rev-parse', `${manifest.releaseSha}^`]).trim() === manifest.sourceSha,
+ 'Release commit must have the validated source as its parent'
+ );
+ const changed = exec('git', ['diff', '--name-only', manifest.sourceSha, manifest.releaseSha])
+ .trim()
+ .split('\n')
+ .filter(Boolean);
+ for (const file of changed) {
+ check(
+ file === 'package.json' ||
+ (manifest.documentationVersion &&
+ (file === 'website/versions.json' ||
+ file.startsWith('website/versioned_docs/') ||
+ file.startsWith('website/versioned_sidebars/'))),
+ `Unexpected release source change: ${file}`
+ );
+ }
+ const before = JSON.parse(exec('git', ['show', `${manifest.sourceSha}:package.json`]));
+ const after = JSON.parse(exec('git', ['show', `${manifest.releaseSha}:package.json`]));
+ check(after.version === manifest.packages[0].version, 'Release commit version mismatch');
+ after.version = before.version;
+ check(
+ JSON.stringify(after) === JSON.stringify(before),
+ 'Release may only change package.json version'
+ );
+}
+
+module.exports = { exec, integrity, validatePackageFiles, pack, readManifest, verifyReleaseCommit };
diff --git a/scripts/release/artifact.test.cjs b/scripts/release/artifact.test.cjs
new file mode 100644
index 00000000000..50306352cd6
--- /dev/null
+++ b/scripts/release/artifact.test.cjs
@@ -0,0 +1,100 @@
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const adapter = require('./rnn.cjs');
+const { exec, integrity, validatePackageFiles, verifyReleaseCommit } = require('./artifact.cjs');
+
+const files = [...adapter.requiredFiles, 'ios/Navigation.mm', 'android/src/Navigation.java'];
+const pkg = {
+ name: adapter.packageName,
+ repository: { url: `https://github.com/${adapter.repository}.git` },
+ main: './lib/module/index.js',
+ types: './lib/typescript/index.d.ts',
+ 'react-native': './src/index.ts',
+ bin: { 'rnn-link': './autolink/postlink/run.js' },
+ exports: {
+ './Mock': { default: './lib/module/Mock/index.js', types: './lib/typescript/Mock/index.d.ts' },
+ },
+};
+
+test('package checks include exported paths, native sources, and accidental credentials', () => {
+ validatePackageFiles(files, pkg);
+ assert.throws(
+ () =>
+ validatePackageFiles(
+ files.filter((f) => !f.startsWith('ios/')),
+ pkg
+ ),
+ /iOS/
+ );
+ assert.throws(
+ () => validatePackageFiles(files, { ...pkg, types: './missing.d.ts' }),
+ /Unresolvable/
+ );
+ for (const bad of [
+ 'android/.gradle/cache',
+ '.npmrc',
+ 'ios/key.keystore',
+ 'playground/index.js',
+ '../escape',
+ ]) {
+ assert.throws(() => validatePackageFiles([...files, bad], pkg));
+ }
+});
+
+test('integrity detects changes to saved package bytes', () => {
+ const folder = fs.mkdtempSync(path.join(os.tmpdir(), 'rnn-integrity-'));
+ try {
+ const file = path.join(folder, 'package.tgz');
+ fs.writeFileSync(file, 'original');
+ const first = integrity(file);
+ fs.writeFileSync(file, 'changed');
+ assert.notEqual(integrity(file), first);
+ } finally {
+ fs.rmSync(folder, { recursive: true, force: true });
+ }
+});
+
+test('real git bundles permit only the recorded version change on the validated source', () => {
+ const previousCwd = process.cwd();
+ const folder = fs.mkdtempSync(path.join(os.tmpdir(), 'rnn-release-git-'));
+ try {
+ process.chdir(folder);
+ exec('git', ['init', '-q']);
+ exec('git', ['config', 'user.name', 'Release test']);
+ exec('git', ['config', 'user.email', 'release@example.test']);
+ fs.writeFileSync(
+ 'package.json',
+ JSON.stringify({ name: adapter.packageName, version: '1.0.0' })
+ );
+ exec('git', ['add', 'package.json']);
+ exec('git', ['commit', '-qm', 'source']);
+ const sourceSha = exec('git', ['rev-parse', 'HEAD']).trim();
+ fs.writeFileSync(
+ 'package.json',
+ JSON.stringify({ name: adapter.packageName, version: '1.0.1' })
+ );
+ exec('git', ['commit', '-qam', 'version']);
+ const releaseSha = exec('git', ['rev-parse', 'HEAD']).trim();
+ exec('git', ['bundle', 'create', 'release.bundle', 'HEAD', `^${sourceSha}`]);
+ verifyReleaseCommit({ sourceSha, releaseSha, packages: [{ version: '1.0.1' }] }, folder);
+ fs.writeFileSync('runtime.js', 'unexpected code');
+ exec('git', ['add', 'runtime.js']);
+ exec('git', ['commit', '--amend', '-qm', 'unexpected mutation']);
+ const changedSha = exec('git', ['rev-parse', 'HEAD']).trim();
+ exec('git', ['bundle', 'create', 'release.bundle', 'HEAD', `^${sourceSha}`]);
+ assert.throws(
+ () =>
+ verifyReleaseCommit(
+ { sourceSha, releaseSha: changedSha, packages: [{ version: '1.0.1' }] },
+ folder
+ ),
+ /Unexpected release source change/
+ );
+ } finally {
+ process.chdir(previousCwd);
+ fs.rmSync(folder, { recursive: true, force: true });
+ }
+});
diff --git a/scripts/release/github.cjs b/scripts/release/github.cjs
new file mode 100644
index 00000000000..83a4d9053c0
--- /dev/null
+++ b/scripts/release/github.cjs
@@ -0,0 +1,183 @@
+const adapter = require('./rnn.cjs');
+const {
+ check,
+ assertReleaseContext,
+ isReleaseRunPath,
+ assertNativeCI,
+ compareVersions,
+} = require('./policy.cjs');
+const prefix = `/repos/${adapter.repository}`;
+
+async function authorize(github, context) {
+ assertReleaseContext(context);
+ for (const actor of new Set([context.actor, context.triggeringActor])) {
+ check(/^[a-zA-Z0-9-]+(?:\[bot\])?$/.test(actor), 'Invalid GitHub actor');
+ const user = await github.request(
+ 'GET',
+ `${prefix}/collaborators/${encodeURIComponent(actor)}/permission`
+ );
+ check(user.permission === 'admin', `${actor} must have repository admin permission`);
+ }
+}
+
+async function nativeCI(github, sha) {
+ check(/^[a-f0-9]{40}$/.test(sha), 'Native CI requires an exact source SHA');
+ const statuses = await github.request('GET', `${prefix}/commits/${sha}/statuses?per_page=100`);
+ return assertNativeCI(statuses, sha);
+}
+
+async function checkSourceRun(github, runId) {
+ check(/^[1-9]\d*$/.test(runId), 'Resume requires a numeric source_run_id');
+ const run = await github.request('GET', `${prefix}/actions/runs/${runId}`);
+ check(
+ run.event === 'workflow_dispatch' &&
+ run.head_branch === adapter.branch &&
+ isReleaseRunPath(run.path) &&
+ run.repository.full_name === adapter.repository &&
+ run.head_repository.full_name === adapter.repository &&
+ run.status === 'completed',
+ 'Resume source must be a completed upstream master release workflow'
+ );
+ const artifacts = [];
+ for (let page = 1; ; page++) {
+ const batch = await github.request(
+ 'GET',
+ `${prefix}/actions/runs/${runId}/artifacts?per_page=100&page=${page}`
+ );
+ artifacts.push(...batch.artifacts);
+ if (batch.artifacts.length < 100) break;
+ }
+ const candidates = artifacts
+ .filter((item) => new RegExp(`^release-${runId}-[1-9]\\d*$`).test(item.name) && !item.expired)
+ .sort((a, b) => Number(b.id) - Number(a.id));
+ check(candidates.length, 'No retained release artifact; automatic resume is unavailable');
+ return { run, artifact: candidates[0] };
+}
+
+async function ensureBranch(github, name, sha) {
+ const endpoint = `${prefix}/git/ref/heads/${name}`;
+ const existing = await github.request('GET', endpoint, undefined, { allow404: true });
+ if (existing) {
+ check(
+ existing.object.sha === sha,
+ `Release branch ${name} already points to a different commit`
+ );
+ return;
+ }
+ await github.request('POST', `${prefix}/git/refs`, { ref: `refs/heads/${name}`, sha });
+}
+
+async function ensureTag(github, tag, sha) {
+ const existing = await github.request('GET', `${prefix}/git/ref/tags/${tag}`, undefined, {
+ allow404: true,
+ });
+ if (existing) {
+ const target =
+ existing.object.type === 'tag'
+ ? await github.request('GET', `${prefix}/git/tags/${existing.object.sha}`)
+ : existing;
+ check(target.object.sha === sha, `Tag ${tag} already points to a different commit`);
+ return;
+ }
+ const object = await github.request('POST', `${prefix}/git/tags`, {
+ tag,
+ message: tag,
+ object: sha,
+ type: 'commit',
+ });
+ await github.request('POST', `${prefix}/git/refs`, { ref: `refs/tags/${tag}`, sha: object.sha });
+}
+
+async function ensureRelease(github, manifest, notes) {
+ const pkg = manifest.packages[0];
+ const existing = await github.request(
+ 'GET',
+ `${prefix}/releases/tags/${pkg.version}`,
+ undefined,
+ { allow404: true }
+ );
+ if (existing) {
+ check(
+ !existing.draft && existing.prerelease === (pkg.npmTag !== 'latest'),
+ 'Existing GitHub release has conflicting state'
+ );
+ return existing;
+ }
+ // Resuming an old release must never replace a newer GitHub "latest" release.
+ const latest = await github.request('GET', `${prefix}/releases/latest`, undefined, {
+ allow404: true,
+ });
+ let makeLatest = pkg.npmTag === 'latest';
+ if (makeLatest && latest) {
+ try {
+ makeLatest = compareVersions(pkg.version, latest.tag_name) > 0;
+ } catch {
+ makeLatest = false;
+ }
+ }
+ return github.request('POST', `${prefix}/releases`, {
+ tag_name: pkg.version,
+ target_commitish: manifest.sourceSha,
+ name: pkg.version,
+ body: notes,
+ draft: false,
+ prerelease: pkg.npmTag !== 'latest',
+ make_latest: makeLatest ? 'true' : 'false',
+ });
+}
+
+async function ensurePullRequest(github, manifest, template = '') {
+ const pkg = manifest.packages[0];
+ const branch = `ci/update-version-${pkg.version}`;
+ const results = await github.request(
+ 'GET',
+ `${prefix}/pulls?state=all&head=wix:${branch}&base=${adapter.branch}&per_page=100`
+ );
+ const existing = results.find((pr) => pr.head.ref === branch && pr.base.ref === adapter.branch);
+ let pr = existing;
+ if (existing) {
+ check(
+ existing.head.sha === manifest.releaseSha,
+ 'Version PR no longer matches the release commit'
+ );
+ check(
+ existing.state === 'open' || existing.merged_at,
+ 'Version PR was closed without merging; resolve it manually'
+ );
+ } else {
+ pr = await github.request('POST', `${prefix}/pulls`, {
+ title: `Release: update version to ${pkg.version}`,
+ head: branch,
+ base: adapter.branch,
+ body: `${template ? `${template.trim()}\n\n` : ''}Updates release metadata${
+ manifest.documentationVersion ? ' and versioned documentation' : ''
+ } for react-native-navigation ${pkg.version}.\n\nPublished from ${
+ manifest.sourceSha
+ }.\n\nRelease run: https://github.com/${adapter.repository}/actions/runs/${
+ manifest.runId
+ }\n\nThe workflow verified the package tarball and registry integrity. Review and merge this version update; no native source changes are included.`,
+ });
+ }
+ const label = await github.request('GET', `${prefix}/labels/${adapter.label}`, undefined, {
+ allow404: true,
+ });
+ if (!label)
+ await github.request('POST', `${prefix}/labels`, {
+ name: adapter.label,
+ color: '0e8a16',
+ description: 'Release metadata and publishing',
+ });
+ await github.request('POST', `${prefix}/issues/${pr.number}/labels`, { labels: [adapter.label] });
+ return pr;
+}
+
+module.exports = {
+ prefix,
+ authorize,
+ nativeCI,
+ checkSourceRun,
+ ensureBranch,
+ ensureTag,
+ ensureRelease,
+ ensurePullRequest,
+};
diff --git a/scripts/release/github.test.cjs b/scripts/release/github.test.cjs
new file mode 100644
index 00000000000..64b940018d3
--- /dev/null
+++ b/scripts/release/github.test.cjs
@@ -0,0 +1,241 @@
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const adapter = require('./rnn.cjs');
+const {
+ authorize,
+ nativeCI,
+ checkSourceRun,
+ ensureTag,
+ ensurePullRequest,
+ ensureRelease,
+} = require('./github.cjs');
+
+const sourceSha = 'a'.repeat(40);
+const manifest = (version = '8.9.0', changes = {}) => ({
+ sourceSha,
+ releaseSha: sourceSha,
+ runId: '12345',
+ documentationVersion: '',
+ packages: [{ name: adapter.packageName, version, npmTag: 'latest' }],
+ ...changes,
+});
+const context = {
+ repository: adapter.repository,
+ ref: `refs/heads/${adapter.branch}`,
+ event: 'workflow_dispatch',
+ sha: sourceSha,
+ runId: '12345',
+ runAttempt: '1',
+ actor: 'release-admin',
+ triggeringActor: 'approver-admin',
+};
+
+function mockGitHub(handler) {
+ const calls = [];
+ return {
+ calls,
+ request: async (...args) => {
+ calls.push(args);
+ return handler(...args);
+ },
+ };
+}
+
+test('authorize requires admin permission for both workflow actors and rejects permission request failures', async () => {
+ const github = mockGitHub((_method, path) => {
+ if (path.endsWith('/release-admin/permission')) return { permission: 'admin' };
+ if (path.endsWith('/approver-admin/permission')) return { permission: 'admin' };
+ throw new Error('unexpected request');
+ });
+ await authorize(github, context);
+ assert.equal(github.calls.length, 2);
+ assert.ok(github.calls.every(([method]) => method === 'GET'));
+
+ for (const deniedActor of ['release-admin', 'approver-admin']) {
+ const deniedGitHub = mockGitHub((_method, path) =>
+ path.endsWith(`/${deniedActor}/permission`)
+ ? { permission: 'write' }
+ : { permission: 'admin' }
+ );
+ await assert.rejects(authorize(deniedGitHub, context), /must have repository admin permission/);
+ assert.equal(deniedGitHub.calls.length, deniedActor === context.actor ? 1 : 2);
+ }
+ const failed = mockGitHub(() => {
+ throw new Error('GitHub unavailable');
+ });
+ await assert.rejects(authorize(failed, context), /GitHub unavailable/);
+});
+
+test('native CI reads the exact commit status history and returns the newest Buildkite status', async () => {
+ const expected = {
+ context: adapter.nativeStatus,
+ state: 'success',
+ target_url: `${adapter.nativeStatusUrl}2671`,
+ creator: adapter.nativeStatusCreator,
+ };
+ const github = mockGitHub((_method, path) => {
+ assert.equal(path, `/repos/${adapter.repository}/commits/${sourceSha}/statuses?per_page=100`);
+ return [{ context: 'other', state: 'failure' }, expected];
+ });
+ assert.equal(await nativeCI(github, sourceSha), expected);
+ assert.equal(github.calls.length, 1);
+ await assert.rejects(nativeCI(github, 'master'), /exact source SHA/);
+ assert.equal(github.calls.length, 1, 'invalid refs must not reach GitHub');
+});
+
+test('checkSourceRun validates workflow identity and selects the newest retained matching artifact', async () => {
+ const validRun = {
+ event: 'workflow_dispatch',
+ head_branch: adapter.branch,
+ path: `${adapter.workflow}@master`,
+ repository: { full_name: adapter.repository },
+ head_repository: { full_name: adapter.repository },
+ status: 'completed',
+ };
+ const github = mockGitHub((_method, path) => {
+ if (path.endsWith('/actions/runs/12345')) return validRun;
+ if (path.endsWith('/artifacts?per_page=100&page=1'))
+ return {
+ artifacts: [
+ { id: 4, name: 'release-12345-1', expired: false },
+ { id: 10, name: 'release-12345-2', expired: true },
+ { id: 12, name: 'unrelated', expired: false },
+ { id: 8, name: 'release-12345-2', expired: false },
+ ],
+ };
+ throw new Error(`unexpected request ${path}`);
+ });
+ const result = await checkSourceRun(github, '12345');
+ assert.equal(result.run, validRun);
+ assert.equal(result.artifact.id, 8);
+
+ for (const patch of [
+ { path: 'elsewhere.yml' },
+ { path: `${adapter.workflow}@feature` },
+ { path: `${adapter.workflow}@refs/heads/feature` },
+ { head_branch: 'feature' },
+ { repository: { full_name: 'fork/project' }, head_repository: { full_name: 'fork/project' } },
+ { status: 'in_progress' },
+ ]) {
+ const invalid = mockGitHub((_method, path) =>
+ path.endsWith('/actions/runs/12345') ? { ...validRun, ...patch } : { artifacts: [] }
+ );
+ await assert.rejects(
+ checkSourceRun(invalid, '12345'),
+ /completed upstream master release workflow/
+ );
+ assert.equal(invalid.calls.length, 1, 'invalid run should not trigger artifact lookup');
+ }
+ const expired = mockGitHub((_method, path) =>
+ path.endsWith('/actions/runs/12345')
+ ? validRun
+ : { artifacts: [{ id: 9, name: 'release-12345-1', expired: true }] }
+ );
+ await assert.rejects(checkSourceRun(expired, '12345'), /No retained release artifact/);
+});
+
+test('ensureTag reuses matching annotated and lightweight tags and rejects conflicts', async () => {
+ const annotatedSha = 'b'.repeat(40);
+ const annotated = mockGitHub((_method, path) => {
+ if (path.endsWith('/git/ref/tags/v8.9.0'))
+ return { object: { type: 'tag', sha: 'tag-object' } };
+ if (path.endsWith('/git/tags/tag-object')) return { object: { sha: sourceSha } };
+ throw new Error(`unexpected request ${path}`);
+ });
+ await ensureTag(annotated, 'v8.9.0', sourceSha);
+ assert.equal(annotated.calls.length, 2);
+
+ const lightweight = mockGitHub(() => ({ object: { type: 'commit', sha: sourceSha } }));
+ await ensureTag(lightweight, 'v8.9.0', sourceSha);
+ assert.equal(lightweight.calls.length, 1);
+ const conflict = mockGitHub((_method, path) =>
+ path.endsWith('/git/ref/tags/v8.9.0')
+ ? { object: { type: 'commit', sha: annotatedSha } }
+ : undefined
+ );
+ await assert.rejects(ensureTag(conflict, 'v8.9.0', sourceSha), /different commit/);
+ assert.equal(conflict.calls.length, 1);
+});
+
+test('ensurePullRequest reuses matching open or merged PRs, applies labels, and rejects unsafe PRs', async () => {
+ for (const existing of [
+ { number: 51, state: 'open', merged_at: null },
+ { number: 52, state: 'closed', merged_at: '2026-01-01T00:00:00Z' },
+ ]) {
+ const pr = {
+ ...existing,
+ head: { ref: 'ci/update-version-8.9.0', sha: sourceSha },
+ base: { ref: adapter.branch },
+ };
+ const github = mockGitHub((_method, path) => {
+ if (path.includes('/pulls?')) return [pr];
+ if (path.endsWith(`/labels/${adapter.label}`)) return { name: adapter.label };
+ if (path.endsWith(`/issues/${existing.number}/labels`)) return undefined;
+ throw new Error(`unexpected request ${path}`);
+ });
+ assert.equal(await ensurePullRequest(github, manifest()), pr);
+ const labelCall = github.calls.find(
+ ([method, path]) => method === 'POST' && path.endsWith(`/issues/${existing.number}/labels`)
+ );
+ assert.deepEqual(labelCall[2], { labels: [adapter.label] });
+ assert.equal(
+ github.calls.filter(([method]) => method === 'POST' && method.includes('/pulls')).length,
+ 0
+ );
+ }
+
+ for (const pr of [
+ {
+ number: 53,
+ state: 'closed',
+ merged_at: null,
+ head: { ref: 'ci/update-version-8.9.0', sha: sourceSha },
+ base: { ref: adapter.branch },
+ },
+ {
+ number: 54,
+ state: 'open',
+ merged_at: null,
+ head: { ref: 'ci/update-version-8.9.0', sha: 'c'.repeat(40) },
+ base: { ref: adapter.branch },
+ },
+ ]) {
+ const github = mockGitHub((_method, path) => (path.includes('/pulls?') ? [pr] : undefined));
+ await assert.rejects(
+ ensurePullRequest(github, manifest()),
+ pr.number === 53 ? /closed without merging/ : /no longer matches/
+ );
+ assert.equal(github.calls.length, 1, 'invalid PR should not be labeled or changed');
+ }
+});
+
+test('ensureRelease does not move latest backward when recovering an older stable release', async () => {
+ const github = mockGitHub((method, path, body) => {
+ if (path.endsWith('/releases/tags/8.9.0')) return undefined;
+ if (path.endsWith('/releases/latest')) return { tag_name: '8.10.0' };
+ if (method === 'POST' && path.endsWith('/releases')) return body;
+ throw new Error(`unexpected request ${path}`);
+ });
+ const release = await ensureRelease(github, manifest(), 'notes');
+ assert.equal(release.make_latest, 'false');
+
+ const newerGitHub = mockGitHub((method, path, body) => {
+ if (path.endsWith('/releases/tags/8.11.0')) return undefined;
+ if (path.endsWith('/releases/latest')) return { tag_name: '8.10.0' };
+ if (method === 'POST' && path.endsWith('/releases')) return body;
+ throw new Error(`unexpected request ${path}`);
+ });
+ const newer = await ensureRelease(newerGitHub, manifest('8.11.0'), 'notes');
+ assert.equal(newer.make_latest, 'true');
+});
+
+test('ensureRelease targets the source commit, not the release commit that only the version PR contains', async () => {
+ const github = mockGitHub((method, path, body) => {
+ if (path.endsWith('/releases/tags/8.9.0')) return undefined;
+ if (path.endsWith('/releases/latest')) return undefined;
+ if (method === 'POST' && path.endsWith('/releases')) return body;
+ throw new Error(`unexpected request ${path}`);
+ });
+ const release = await ensureRelease(github, manifest('8.9.0', { releaseSha: 'b'.repeat(40) }), 'notes');
+ assert.equal(release.target_commitish, sourceSha);
+});
diff --git a/scripts/release/http.cjs b/scripts/release/http.cjs
new file mode 100644
index 00000000000..7774d1531ca
--- /dev/null
+++ b/scripts/release/http.cjs
@@ -0,0 +1,48 @@
+const { setTimeout: delay } = require('node:timers/promises');
+const { check } = require('./policy.cjs');
+
+async function jsonRequest(url, options = {}) {
+ const { allow404 = false, fetchImpl = fetch, sleep = delay, ...init } = options;
+ for (let attempt = 0; attempt < 3; attempt++) {
+ const response = await fetchImpl(url, { ...init, signal: AbortSignal.timeout(30_000) });
+ if (response.status === 404 && allow404) return null;
+ if ((response.status === 429 || response.status >= 500) && attempt < 2) {
+ await sleep(1000 * 2 ** attempt);
+ continue;
+ }
+ // Never echo request headers or arbitrary server response bodies (credentials).
+ check(response.ok, `HTTP ${response.status} from ${new URL(url).host}${new URL(url).pathname}`);
+ if (response.status === 204) return null;
+ return response.json();
+ }
+ throw new Error('HTTP retry limit reached');
+}
+
+function githubClient(token, request = jsonRequest) {
+ check(token, 'GH_TOKEN is required');
+ return {
+ request(method, endpoint, body, options = {}) {
+ check(endpoint.startsWith('/repos/'), 'Only repository GitHub API endpoints are allowed');
+ return request(`https://api.github.com${endpoint}`, {
+ ...options,
+ method,
+ headers: {
+ Authorization: `Bearer ${token}`,
+ Accept: 'application/vnd.github+json',
+ 'X-GitHub-Api-Version': '2022-11-28',
+ 'Content-Type': 'application/json',
+ },
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
+ });
+ },
+ };
+}
+
+function registryVersion(name, selected, options = {}) {
+ return jsonRequest(
+ `https://registry.npmjs.org/${encodeURIComponent(name)}/${encodeURIComponent(selected)}`,
+ options
+ );
+}
+
+module.exports = { jsonRequest, githubClient, registryVersion };
diff --git a/scripts/release/http.test.cjs b/scripts/release/http.test.cjs
new file mode 100644
index 00000000000..ab7c68ec5c6
--- /dev/null
+++ b/scripts/release/http.test.cjs
@@ -0,0 +1,50 @@
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const { jsonRequest } = require('./http.cjs');
+
+test('only an explicitly allowed 404 means a package is absent', async () => {
+ const fetchImpl = async () => ({ status: 404, ok: false });
+ assert.equal(
+ await jsonRequest('https://registry.npmjs.org/p/1', { fetchImpl, allow404: true }),
+ null
+ );
+ await assert.rejects(
+ jsonRequest('https://registry.npmjs.org/p/latest', { fetchImpl }),
+ /HTTP 404/
+ );
+ await assert.rejects(
+ jsonRequest('https://registry.npmjs.org/p/1', {
+ fetchImpl: async () => ({ status: 401, ok: false }),
+ allow404: true,
+ }),
+ /HTTP 401/
+ );
+});
+
+test('registry outages retry and fail rather than selecting a new version', async () => {
+ let attempts = 0;
+ await assert.rejects(
+ jsonRequest('https://registry.npmjs.org/p/1', {
+ fetchImpl: async () => {
+ attempts++;
+ return { status: 503, ok: false };
+ },
+ sleep: async () => {},
+ allow404: true,
+ }),
+ /HTTP 503/
+ );
+ assert.equal(attempts, 3);
+});
+
+test('network errors cannot be mistaken for a missing version', async () => {
+ await assert.rejects(
+ jsonRequest('https://registry.npmjs.org/p/1', {
+ fetchImpl: async () => {
+ throw new Error('offline');
+ },
+ allow404: true,
+ }),
+ /offline/
+ );
+});
diff --git a/scripts/release/index.cjs b/scripts/release/index.cjs
new file mode 100644
index 00000000000..55612046da4
--- /dev/null
+++ b/scripts/release/index.cjs
@@ -0,0 +1,395 @@
+const fs = require('node:fs');
+const path = require('node:path');
+const adapter = require('./rnn.cjs');
+const {
+ check,
+ version,
+ compareVersions,
+ createPlan,
+ isReleaseRunPath,
+ publicationState,
+} = require('./policy.cjs');
+const { githubClient, registryVersion } = require('./http.cjs');
+const {
+ prefix,
+ authorize,
+ nativeCI,
+ checkSourceRun,
+ ensureTag,
+ ensureRelease,
+ ensurePullRequest,
+} = require('./github.cjs');
+const { exec, integrity, pack, readManifest, verifyReleaseCommit } = require('./artifact.cjs');
+const { renderNotes } = require('./notes.cjs');
+const { publishPackages } = require('./publication.cjs');
+
+const directory = path.resolve(process.env.RELEASE_DIR || '.release-artifacts');
+const context = {
+ repository: process.env.GITHUB_REPOSITORY,
+ ref: process.env.GITHUB_REF,
+ event: process.env.GITHUB_EVENT_NAME,
+ sha: process.env.GITHUB_SHA,
+ runId: process.env.GITHUB_RUN_ID,
+ runAttempt: process.env.GITHUB_RUN_ATTEMPT,
+ actor: process.env.GITHUB_ACTOR,
+ triggeringActor: process.env.GITHUB_TRIGGERING_ACTOR,
+};
+
+function output(name, value) {
+ if (process.env.GITHUB_OUTPUT) fs.appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`);
+}
+
+function summary(text) {
+ console.log(text);
+ if (process.env.GITHUB_STEP_SUMMARY)
+ fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${text}\n`);
+}
+
+function readPlan() {
+ return JSON.parse(fs.readFileSync(path.join(directory, 'plan.json'), 'utf8'));
+}
+
+function writeJSON(file, value) {
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
+}
+
+async function guard(github, publishing = false) {
+ await authorize(github, context);
+ check(
+ ['dry-run', 'publish', 'resume'].includes(process.env.RELEASE_OPERATION),
+ 'Invalid operation'
+ );
+ if (publishing || process.env.RELEASE_OPERATION !== 'dry-run') {
+ check(
+ process.env.RELEASE_PUBLISH_ENABLED === 'true',
+ 'Set repository variable RELEASE_PUBLISH_ENABLED=true after configuring npm trust and the release environment'
+ );
+ }
+}
+
+async function verifyOrigin(github, manifest) {
+ const run = await github.request('GET', `${prefix}/actions/runs/${manifest.runId}`);
+ check(
+ run.event === 'workflow_dispatch' &&
+ isReleaseRunPath(run.path) &&
+ run.head_branch === adapter.branch &&
+ run.repository.full_name === adapter.repository &&
+ run.head_repository.full_name === adapter.repository &&
+ run.head_sha === manifest.sourceSha &&
+ run.actor.login === manifest.actor &&
+ Number(run.run_attempt) >= Number(manifest.runAttempt),
+ 'Artifact does not match its originating release workflow'
+ );
+ exec('git', ['merge-base', '--is-ancestor', manifest.sourceSha, context.sha]);
+}
+
+async function verifiedArtifact(github) {
+ await guard(github, true);
+ const manifest = readManifest(directory, { publishing: true });
+ await verifyOrigin(github, manifest);
+ await nativeCI(github, manifest.sourceSha);
+ verifyReleaseCommit(manifest, directory);
+ return manifest;
+}
+
+async function plan(github) {
+ await guard(github);
+ check(
+ exec('git', ['rev-parse', 'HEAD']).trim() === context.sha,
+ 'Checkout differs from workflow source SHA'
+ );
+ const current = JSON.parse(fs.readFileSync('package.json', 'utf8'));
+ const latest = await registryVersion(adapter.packageName, 'latest');
+ const manifest = createPlan(
+ {
+ operation: process.env.RELEASE_OPERATION,
+ kind: process.env.RELEASE_KIND,
+ version: process.env.RELEASE_VERSION,
+ npmTag: process.env.RELEASE_NPM_TAG,
+ documentationVersion: process.env.RELEASE_DOCUMENTATION_VERSION,
+ removeDocumentationVersion: process.env.RELEASE_REMOVE_DOCUMENTATION_VERSION,
+ },
+ context,
+ current.version,
+ latest.version
+ );
+ const pkg = manifest.packages[0];
+ const existing = await registryVersion(pkg.name, pkg.version, { allow404: true });
+ check(
+ !existing,
+ `${pkg.name}@${pkg.version} already exists. Use resume with the original publishing run.`
+ );
+ try {
+ manifest.nativeCI = await nativeCI(github, context.sha);
+ } catch (error) {
+ if (manifest.operation !== 'dry-run') throw error;
+ summary(
+ `Native CI is not ready for publication: ${error.message}. This dry run only verifies package preparation.`
+ );
+ }
+ const tags = exec('git', ['tag', '--merged', context.sha])
+ .trim()
+ .split('\n')
+ .filter((tag) => {
+ try {
+ return !version(tag).pre.length && compareVersions(tag, pkg.version) < 0;
+ } catch {
+ return false;
+ }
+ })
+ .sort(compareVersions);
+ manifest.previousTag = tags.at(-1) || '';
+ check(
+ manifest.previousTag,
+ 'No preceding reachable stable tag; define the initial release baseline before publishing'
+ );
+ writeJSON(path.join(directory, 'plan.json'), manifest);
+ output('documentation_version', manifest.documentationVersion);
+ summary(
+ `Planned ${pkg.name}@${pkg.version} → ${pkg.npmTag}\n\nSource: ${manifest.sourceSha}\n\nOperation: ${manifest.operation}`
+ );
+}
+
+function setVersion() {
+ const manifest = readPlan();
+ const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
+ pkg.version = manifest.packages[0].version;
+ writeJSON('package.json', pkg);
+ if (manifest.documentationVersion) {
+ require('../documentation').release(
+ manifest.documentationVersion,
+ manifest.removeDocumentationVersion
+ );
+ }
+}
+
+async function prepareArtifact(github) {
+ const manifest = readPlan();
+ await guard(github);
+ const pkg = manifest.packages[0];
+ // Exclude untracked build output; only explicit version/documentation paths may
+ // enter the release commit. The bundle verifier checks the resulting diff again.
+ exec('git', ['add', '--', 'package.json']);
+ if (manifest.documentationVersion) {
+ exec('git', [
+ 'add',
+ '--',
+ 'website/versions.json',
+ 'website/versioned_docs',
+ 'website/versioned_sidebars',
+ ]);
+ }
+ const date = exec('git', ['show', '-s', '--format=%cI', manifest.sourceSha]).trim();
+ exec(
+ 'git',
+ [
+ '-c',
+ 'user.name=github-actions[bot]',
+ '-c',
+ 'user.email=41898282+github-actions[bot]@users.noreply.github.com',
+ 'commit',
+ '--allow-empty',
+ '--no-verify',
+ '-m',
+ `Release ${pkg.version}`,
+ ],
+ {
+ env: { ...process.env, GIT_AUTHOR_DATE: date, GIT_COMMITTER_DATE: date },
+ }
+ );
+ manifest.releaseSha = exec('git', ['rev-parse', 'HEAD']).trim();
+ check(
+ !exec('git', ['status', '--porcelain', '--untracked-files=all']).trim(),
+ 'Build/install changed files outside the permitted release commit'
+ );
+ Object.assign(pkg, pack(directory));
+ check(
+ !exec('git', ['status', '--porcelain', '--untracked-files=all']).trim(),
+ 'Packing changed source files; refusing to release an uncommitted tree'
+ );
+ exec('git', [
+ 'bundle',
+ 'create',
+ path.join(directory, 'release.bundle'),
+ 'HEAD',
+ `^${manifest.sourceSha}`,
+ ]);
+ const commits = exec('git', ['rev-list', `${manifest.previousTag}..${manifest.sourceSha}`])
+ .trim()
+ .split('\n')
+ .filter(Boolean);
+ const prs = [];
+ for (const commit of commits) {
+ for (let page = 1; ; page++) {
+ const batch = await github.request(
+ 'GET',
+ `${prefix}/commits/${commit}/pulls?per_page=100&page=${page}`
+ );
+ prs.push(...batch.filter((pr) => pr.base.repo.full_name === adapter.repository));
+ if (batch.length < 100) break;
+ }
+ }
+ const notes = renderNotes({
+ version: pkg.version,
+ previousTag: manifest.previousTag,
+ sourceSha: manifest.sourceSha,
+ pullRequests: prs,
+ });
+ fs.writeFileSync(path.join(directory, 'notes.md'), notes);
+ manifest.files = Object.fromEntries(
+ ['notes.md', 'release.bundle'].map((file) => [file, integrity(path.join(directory, file))])
+ );
+ writeJSON(path.join(directory, 'manifest.json'), manifest);
+ fs.unlinkSync(path.join(directory, 'plan.json'));
+ readManifest(directory);
+ verifyReleaseCommit(manifest, directory);
+ summary(
+ `Prepared ${pkg.filename}\n\nIntegrity: ${pkg.integrity}\n\nRelease commit: ${manifest.releaseSha}\n\n${notes}`
+ );
+}
+
+async function record(github) {
+ const manifest = await verifiedArtifact(github);
+ if (manifest.kind === 'snapshot') return;
+ const branch = `ci/update-version-${manifest.packages[0].version}`;
+ const existing = await github.request('GET', `${prefix}/git/ref/heads/${branch}`, undefined, {
+ allow404: true,
+ });
+ if (existing) {
+ check(
+ existing.object.sha === manifest.releaseSha,
+ `Release branch ${branch} conflicts with this artifact`
+ );
+ return;
+ }
+ // Supply Git authentication only to this process, never in the remote URL or
+ // persistent git config. No force push and no branch-protection bypass.
+ exec('git', ['push', 'origin', `${manifest.releaseSha}:refs/heads/${branch}`], {
+ env: {
+ ...process.env,
+ GIT_CONFIG_COUNT: '1',
+ GIT_CONFIG_KEY_0: 'http.https://github.com/.extraheader',
+ GIT_CONFIG_VALUE_0: `AUTHORIZATION: basic ${Buffer.from(
+ `x-access-token:${process.env.GH_TOKEN}`
+ ).toString('base64')}`,
+ },
+ });
+}
+
+async function publish(github) {
+ const manifest = await verifiedArtifact(github);
+ check(
+ process.env.ACTIONS_ID_TOKEN_REQUEST_URL && process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN,
+ 'Publishing requires GitHub OIDC credentials (id-token: write)'
+ );
+ check(
+ compareVersions(exec('npm', ['--version']).trim(), '11.5.1') >= 0,
+ 'Trusted publishing requires npm >=11.5.1'
+ );
+ await publishPackages(manifest.packages, {
+ lookup: (name, selected) => registryVersion(name, selected, { allow404: true }),
+ report: summary,
+ publish: async (pkg) => {
+ check(
+ context.sha === manifest.sourceSha,
+ 'An unpublished version must run at its original source SHA for npm provenance. Re-run failed jobs of the original publishing run.'
+ );
+ const npmrc = path.join(directory, 'oidc.npmrc');
+ fs.writeFileSync(npmrc, 'registry=https://registry.npmjs.org/\n');
+ const env = { ...process.env, NPM_CONFIG_USERCONFIG: npmrc };
+ delete env.NPM_TOKEN;
+ delete env.NODE_AUTH_TOKEN;
+ exec(
+ 'npm',
+ [
+ 'publish',
+ path.join(directory, pkg.filename),
+ '--ignore-scripts',
+ '--access',
+ 'public',
+ '--tag',
+ pkg.npmTag,
+ ],
+ { cwd: directory, env, stdio: 'inherit' }
+ );
+ },
+ });
+}
+
+async function finalize(github) {
+ const manifest = await verifiedArtifact(github);
+ for (const pkg of manifest.packages) {
+ check(
+ publicationState(pkg, await registryVersion(pkg.name, pkg.version, { allow404: true })) ===
+ 'complete',
+ 'Cannot finalize an unpublished package'
+ );
+ }
+ if (manifest.kind === 'snapshot') {
+ summary('Snapshot publication verified; no Git tag or version PR is created.');
+ return;
+ }
+ const pkg = manifest.packages[0];
+ // The release commit exists only on the squash-merged version PR branch; tag the master commit that was built and published.
+ await ensureTag(github, pkg.version, manifest.sourceSha);
+ const release = await ensureRelease(
+ github,
+ manifest,
+ fs.readFileSync(path.join(directory, 'notes.md'), 'utf8')
+ );
+ const templates = ['.github/pull_request_template.md', '.github/PULL_REQUEST_TEMPLATE.md'];
+ const template = templates.find((file) => fs.existsSync(file));
+ const pr = await ensurePullRequest(
+ github,
+ manifest,
+ template ? fs.readFileSync(template, 'utf8') : ''
+ );
+ summary(`Release: ${release.html_url}\n\nVersion/documentation PR: ${pr.html_url}`);
+}
+
+async function main() {
+ const command = process.argv[2];
+ if (command === 'pack-check') {
+ const pkg = pack(directory);
+ summary(`Verified package ${pkg.filename} (${pkg.integrity})`);
+ return;
+ }
+ if (command === 'set-version') {
+ setVersion();
+ return;
+ }
+ const github = githubClient(process.env.GH_TOKEN);
+ if (command === 'authorize') return guard(github);
+ if (command === 'plan') return plan(github);
+ if (command === 'prepare-artifact') return prepareArtifact(github);
+ if (command === 'record') return record(github);
+ if (command === 'publish') return publish(github);
+ if (command === 'finalize') return finalize(github);
+ if (command === 'resume-info') {
+ await guard(github, true);
+ const { artifact } = await checkSourceRun(github, process.env.RELEASE_SOURCE_RUN_ID);
+ output('artifact_id', artifact.id);
+ output('source_run_id', process.env.RELEASE_SOURCE_RUN_ID);
+ return;
+ }
+ if (command === 'verify-resume') {
+ const manifest = await verifiedArtifact(github);
+ check(
+ manifest.runId === process.env.RELEASE_SOURCE_RUN_ID,
+ 'Resume artifact belongs to a different run'
+ );
+ summary(
+ `Resuming ${manifest.packages[0].version} from run ${manifest.runId}; new version/docs inputs are ignored.`
+ );
+ return;
+ }
+ throw new Error(`Unknown release command: ${command}`);
+}
+
+main().catch((error) => {
+ // Child-process errors may embed command output. Print the message only; never
+ // serialize environment or the entire error object.
+ console.error(error.message);
+ process.exitCode = 1;
+});
diff --git a/scripts/release/notes.cjs b/scripts/release/notes.cjs
new file mode 100644
index 00000000000..df3332046b1
--- /dev/null
+++ b/scripts/release/notes.cjs
@@ -0,0 +1,105 @@
+const CATEGORIES = [
+ { title: 'Features', labels: ['feature'] },
+ { title: 'Enhancements', labels: ['type: accepted/enhancement', 'internal'] },
+ { title: 'Fixed', labels: ['type: accepted/bug'] },
+ { title: 'Other', labels: [] },
+];
+
+function escapeMarkdown(value) {
+ return String(value ?? '')
+ .replace(/[<>]/g, (char) => (char === '<' ? '<' : '>'))
+ .replace(/[\\`*_{}\[\]()#+!|~]/g, '\\$&');
+}
+
+function labelsOf(pr) {
+ return (Array.isArray(pr.labels) ? pr.labels : [])
+ .map((label) => (typeof label === 'string' ? label : label?.name))
+ .filter((label) => typeof label === 'string');
+}
+
+function comparePRs(a, b) {
+ return (
+ a.number - b.number ||
+ String(a.title).localeCompare(String(b.title)) ||
+ String(a.user?.login).localeCompare(String(b.user?.login))
+ );
+}
+
+function categoryOf(labels) {
+ for (const category of CATEGORIES.slice(0, -1)) {
+ if (category.labels.some((label) => labels.includes(label))) return category.title;
+ }
+ return 'Other';
+}
+
+function platformOf(labels) {
+ const ios = labels.includes('platform: iOS');
+ const android = labels.includes('platform: Android');
+ if (ios === android) return 'general';
+ return ios ? 'iOS only' : 'Android only';
+}
+
+function renderPR(pr, repository) {
+ const title = escapeMarkdown(pr.title);
+ const author = pr.user?.login ? ` — @${escapeMarkdown(pr.user.login)}` : '';
+ return `- [${title}](https://github.com/${repository}/pull/${pr.number})${author}`;
+}
+
+function renderNotes({
+ version,
+ previousTag,
+ sourceSha,
+ pullRequests,
+ repository = 'wix/react-native-navigation',
+}) {
+ if (!/^[\w.-]+\/[\w.-]+$/.test(repository)) throw new TypeError('Invalid GitHub repository');
+ if (typeof version !== 'string' || !version) throw new TypeError('A release version is required');
+ if (!/^[a-f0-9]{40}$/i.test(sourceSha))
+ throw new TypeError('A full source commit SHA is required');
+
+ const byNumber = new Map();
+ for (const pr of pullRequests || []) {
+ if (!Number.isSafeInteger(pr.number) || pr.number < 1 || !pr.merged_at) continue;
+ const labels = labelsOf(pr);
+ if (labels.includes('skip-changelog')) continue;
+ const candidate = { ...pr, _labels: labels };
+ const existing = byNumber.get(pr.number);
+ if (!existing || comparePRs(candidate, existing) < 0) byNumber.set(pr.number, candidate);
+ }
+
+ const prs = [...byNumber.values()].sort((a, b) => a.number - b.number);
+ const lines = [
+ `# ${escapeMarkdown(version)}`,
+ '',
+ `Source commit: [${sourceSha}](https://github.com/${repository}/commit/${sourceSha})`,
+ ];
+ if (previousTag) {
+ lines.push(
+ `Full changes: [${escapeMarkdown(
+ previousTag
+ )}...${sourceSha}](https://github.com/${repository}/compare/${encodeURIComponent(
+ previousTag
+ )}...${sourceSha})`
+ );
+ }
+
+ for (const category of CATEGORIES) {
+ const categoryPRs = prs.filter((pr) => categoryOf(pr._labels) === category.title);
+ if (!categoryPRs.length) continue;
+ lines.push('', `## ${category.title}`);
+ const groups = new Map([
+ ['general', []],
+ ['iOS only', []],
+ ['Android only', []],
+ ]);
+ for (const pr of categoryPRs) groups.get(platformOf(pr._labels)).push(pr);
+ for (const [platform, groupPRs] of groups) {
+ if (!groupPRs.length) continue;
+ if (platform !== 'general') lines.push('', `### ${platform}`);
+ for (const pr of groupPRs) lines.push(renderPR(pr, repository));
+ }
+ }
+ return `${lines.join('\n')}\n`;
+}
+
+module.exports = { renderNotes };
diff --git a/scripts/release/notes.test.cjs b/scripts/release/notes.test.cjs
new file mode 100644
index 00000000000..7b90a0fe058
--- /dev/null
+++ b/scripts/release/notes.test.cjs
@@ -0,0 +1,81 @@
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const { renderNotes } = require('./notes.cjs');
+
+const sha = 'a'.repeat(40);
+function pr(number, title, labels = [], extra = {}) {
+ return {
+ number,
+ title,
+ html_url: 'https://example.com/not-canonical',
+ user: { login: 'contributor' },
+ labels: labels.map((name) => ({ name })),
+ merged_at: '2026-01-02T03:04:05Z',
+ ...extra,
+ };
+}
+
+test('renders categories, platform subgroups, canonical links, source and compare commits', () => {
+ const markdown = renderNotes({
+ version: '8.9.0',
+ previousTag: 'v8.8.9',
+ sourceSha: sha,
+ pullRequests: [
+ pr(12, 'Fix bug', ['type: accepted/bug']),
+ pr(4, 'New feature', ['feature']),
+ pr(7, 'iOS improvement', ['internal', 'platform: iOS']),
+ pr(9, 'Android improvement', ['type: accepted/enhancement', 'platform: Android']),
+ pr(10, 'Cross platform', ['feature', 'platform: iOS', 'platform: Android']),
+ pr(15, 'Uncategorized'),
+ ],
+ });
+ assert.match(markdown, /# 8\.9\.0/);
+ assert.match(markdown, new RegExp(`Source commit: \\[${sha}\\]`));
+ assert.match(markdown, /compare\/v8\.8\.9\.\.\.[a-f0-9]{40}/);
+ assert.ok(
+ markdown.indexOf('[New feature](https://github.com/wix/react-native-navigation/pull/4)') <
+ markdown.indexOf('/pull/10')
+ );
+ assert.match(markdown, /## Features[\s\S]*### iOS only[\s\S]*### Android only/);
+ assert.match(markdown, /## Enhancements[\s\S]*### iOS only[\s\S]*### Android only/);
+ assert.match(markdown, /## Fixed[\s\S]*Fix bug/);
+ assert.match(markdown, /## Other[\s\S]*Uncategorized/);
+ assert.doesNotMatch(markdown, /example\.com/);
+});
+
+test('omits skipped and unmerged PRs and deduplicates by number deterministically', () => {
+ const markdown = renderNotes({
+ version: '8.9.0',
+ previousTag: '',
+ sourceSha: sha,
+ pullRequests: [
+ pr(8, 'z duplicate'),
+ pr(3, 'skip this', ['skip-changelog']),
+ pr(6, 'not merged', [], { merged_at: null }),
+ pr(8, 'a duplicate'),
+ ],
+ });
+ assert.equal((markdown.match(/\/pull\/8/g) || []).length, 1);
+ assert.match(markdown, /\[a duplicate\]/);
+ assert.doesNotMatch(markdown, /skip this|not merged|z duplicate|Full changes/);
+});
+
+test('escapes title, author, and version content and uses the requested canonical repository', () => {
+ const markdown = renderNotes({
+ version: '8.9.0