rn.green shows whether a React Native library works on each React Native version. A cell is
compatible only when the library's demo app builds on iOS and Android and its test suite
passes, if it has one. Patch versions are folded into minor lines (2.1.3 counts as 2.1.x).
The site is static, built with Astro. No cell has a result until the first Codemagic run.
Requires Node 22.12 or newer.
| Command | What it does |
|---|---|
npm install |
Install dependencies |
npm run dev |
Start the dev server at http://localhost:4321 |
npm run build |
Validate the data and build the site to dist/ |
npm run preview |
Serve the built site |
npm run check |
Type check (astro check) |
npm test |
Run the unit tests (vitest) |
Two kinds of file. The build fails, naming the file and field, if either is invalid.
green-packages.toml, the catalog: which packages are covered, which versions are checked, and how each package is tested and built. We write it by hand, new releases included for now.compatibility-data/<package name>/compatibility.json: the results. They arrive through the pull requests thatcompatibility-checkopens; nobody edits them by hand.
schema = 1
[react-native]
versions = ["0.88.0-rc.3", "0.87.1", "0.86.3"]
[[package]]
name = "react-native-screenshot-aware"
repository = "https://github.com/huextrat/react-native-screenshot-aware"
description = "React Native module for real-time screenshot detection on Android and iOS"
license = "MIT"
versions = ["2.1.3", "2.0.0", "1.3.21"]
[package.source]
tag = "v{version}"
dir = "."
[package.setup]
install = ["."]
prepare = [{ dir = ".", run = ["yarn", "prepare"] }]
[package.test]
dir = "."
run = ["yarn", "test"]
[package.demo]
dir = "example"[react-native] versionsare the table columns. Each version is exact and stands for its minor line, one version per line. A version with a prerelease tag (-rc.3) is shown as a release candidate.- Each
[[package]]gets a page at/<name>/and a badge at/badge/<name>.svg.namefollows npm's rules and must be unique.repositoryis anhttps://link.descriptionandlicenseare optional. versionsare the library versions that are checked, one per minor line, newest first by convention.enabled = falsekeeps a package on the site, and the workflow refuses to check it. It is optional; the default istrue.- Unknown keys are an error at every level, so a misspelt field fails the build.
The source, setup, test and demo tables are the package's settings: how a check builds and
tests it. A package without any of them is listed on the site and never checked. Once one is
present, test and demo are both required, so a forgotten test can never turn "tests not run"
into "compatible".
| Setting | Default | Meaning |
|---|---|---|
source.tag |
v{version} |
Release tag. {version} is the only placeholder (@react-navigation/core@{version}) |
source.dir |
. |
Where the package sits in its repository (packages/react-native-reanimated) |
setup.install |
["."] |
Folders to install in, in order, each with the package manager it declares. Some demo apps install separately ([".", "example"]) |
setup.prepare |
[] |
Commands run before the tests and before each native build, such as the package's own build: [{ dir = ".", run = ["yarn", "prepare"] }] |
setup.env |
{} |
Extra environment for every library command ({ RNS_GAMMA_ENABLED = "1" }) |
setup.node |
the workflow's (22) | Node version for library commands ("24", "24.9"). When the workflow's Node does not match, the check downloads the newest matching release from nodejs.org, checks it against the release's SHASUMS256.txt, and puts it first in PATH for library commands only |
test.run |
required | The test command as an argument list, or "none" when the library has no test suite |
test.dir |
Where to run the test command. Required with a command, not allowed with "none" |
|
demo.dir |
required | The demo app that is built (apps/fabric-example). Not . |
demo.ios-scheme |
found from the workspace | Only when the app's scheme is not named after its workspace or project (Debug FabricExample, ReactTestApp) |
Commands are argument lists run without a shell. Every folder must stay inside the repository.
{
"schemaVersion": 2,
"package": "react-native-screenshot-aware",
"results": {
"2.1": {
"0.87": {
"status": "compatible",
"tested": { "library": "2.1.3", "reactNative": "0.87.1" },
"checks": { "buildIos": "passed", "buildAndroid": "passed", "tests": "passed" },
"buildUrl": "https://codemagic.io/app/<app>/build/<build>",
"testedAt": "2026-09-06T02:14:09Z"
}
}
}
}- Outer keys are library lines, inner keys React Native lines, without leading zeros (
0.80, not0.080). packagemust be a package of the catalog and match the folder the file is in.- The file is optional. A package without one has no results; its first pull request creates it.
- A missing cell means the combination has not been tested yet ("Untested" on the site).
testedholds the exact versions tested:tested.libraryis on the outer line andtested.reactNativeon the inner line.buildIosandbuildAndroidarepassedorfailed.testsispassed,failedornone, which means the library has no test suite and is judged on its builds alone.statusmust becompatibleexactly when both builds arepassedandtestsispassedornone.buildUrl(optional) links the cell to its Codemagic build and must start withhttps://codemagic.io/.- Results on a line the catalog no longer lists (either axis) are valid and not shown. They are dropped the next time a result for that package is written. So retiring a React Native column or a library line is a one-line edit of the catalog.
- Add a
[[package]]entry togreen-packages.tomlwith itsversionsand settings, andenabled = false. The site lists it straight away; nothing is checked yet. - Try the settings in a local run that opens no pull request (see "Running a check locally"), for one of its versions on one React Native version. Read the logs and adjust the settings until the setup works. A red build is fine; a setup that fails is not.
- Remove
enabled = false, then start its checks in Codemagic, one per cell. Each one opens or updates the package's pull request.
codemagic.yaml defines one workflow, compatibility-check. Its scripts are in scripts/compat/:
plain Node, no build step, with the logic in scripts/compat/lib/ and its tests beside it.
A person starts it by hand, for one cell: one library version on one React Native version. Its
first step, "Check the token", checks that GITHUB_TOKEN may push to the repository, before anything
is installed, so a token that cannot fails the build in seconds. It then checks the library out at
its release tag, points the library's own demo app at that React Native version, installs, runs the
library's test suite, builds the demo app for Android and for iOS, and saves the three outcomes as
result.json. It follows the package's settings in the catalog. Then it sends the result to this
repository as a pull request. Every run opens or updates the package's pull request; there is no
run that checks without sending the result.
One pull request per package. Results for a package collect on the branch compat/<package>
(compat/@react-navigation/core; a character git does not allow in a branch name is written as
% and its hex code). The branch is always the base branch plus exactly one commit, holding every
result of that package that the base branch does not have yet. Each new result rebuilds that commit
from the base branch and pushes it with a lease on the tip it read, so two builds finishing together
never overwrite each other (the later one starts again), and the pull request never conflicts with
the base branch. Its title counts the results (compat: react-native-screens: 3 results) and its
description lists them, written again from the data each time.
- Review the pull request and merge it (squash is fine). The next result for that package opens a new one with only the new cells.
- If you close a pull request without merging, delete its branch too. Otherwise the next result for that package carries the closed cells forward.
- A result is only written over an older one: a cell the base branch already has with a newer result is left alone.
Which repository. The pull request always goes to codemagic-ci-cd/reactnative.green, fixed as
REPOSITORY in scripts/compat/lib/github.mjs. A fork or a rename changes it there.
When the push fails. Before preparing the commit, the step checks with git that the token may write: a dry run of pushing the base commit to the package's branch, which reaches the repository's receive-pack and changes nothing. The push itself is then told apart by git's answer:
- The branch moved since it was read (the lease no longer matches): the step starts again from the new tip, up to five times.
- GitHub refused the token (401 or 403, "Permission … denied", authentication failed, repository not
found), at the check or at the push: the step fails at once, naming the repository and the account
GitHub reported, with the likely causes: the token's resource owner is a personal account and not
the organization; the organization has not approved the token; the repository is not among the
token's selected repositories; Contents is not read and write. (For a classic token: it lacks the
public_repoorreposcope.) - Anything else, such as a branch rule: the step fails at once with git's own message.
A run whose setup fails (bad inputs, no such tag, the swap, the Node download, the install) leaves no result and no pull request.
Detection of new releases is not active. Versions in green-packages.toml are edited by hand for
now. The code for detecting them (scripts/compat/watch.mjs and its libraries) is kept in the
repository for later; no workflow runs it.
One variable group, default, with one variable, GITHUB_TOKEN: a fine-grained GitHub token
for this repository only, with Contents and Pull requests set to read and write. Its
resource owner must be the organization that owns the repository, not a personal account, and the
organization must approve it. The last
step uses it to push the package's branch and to open or update its pull request. Mark the value
Secret and give the token an expiry date.
How the token is handled:
- git gets it through a credential helper that reads the variable, so it never appears in a remote
URL, in
.git/configor in a log. Those git commands ignore the machine's git configuration and run no hooks. - The GitHub API gets it only at
https://api.github.com, in theAuthorizationheader. Redirects to any other host are not followed. - Every command the scripts start for the library (installs, tests, Gradle, CocoaPods,
xcodebuild) runs without it in its environment.
The remaining risk. The same build runs the library's own code: its install scripts, tests and
build scripts. Keeping the token out of their environment is not isolation. On a build machine a
process can read the environment of the processes that started it (ps eww on macOS), and code that
runs in an early step can change what a later step runs, including the script that uses the token.
So a hostile or compromised library, or one of its dependencies, could take the token and push to
this repository or open pull requests with it. What limits the damage:
- Protect
mainso that it only changes through a pull request with an approving review, and so that nobody pushes to it directly. Then the token can at most push other branches and open pull requests, which a person reviews before anything reaches the site. - Keep the token limited to this repository, with only the two permissions above.
- Give it an expiry date, and replace it if a check ever misbehaves.
- Add the app to Codemagic.
- In the app's settings, create the variable group
defaultwithGITHUB_TOKEN(see "Credentials"), and mark the value Secret. - Start
compatibility-checkby hand for one cell (see "Starting one check by hand"). The first build already opens a pull request for the package. Read the build's logs. If its first step, "Check the token", fails, fix the token as its message says before trying anything else. - Review the pull request and merge it.
There is no schedule, no Codemagic API token and no webhook.
In the Codemagic UI, start compatibility-check and fill in package, library_version and
react_native_version. The run opens or updates the package's pull request.
Inputs are checked before anything runs, and again against the base branch's catalog before the result is sent. The package must be in the catalog with settings and not disabled, the versions must be exact, and both must be on lines the catalog lists.
The library's own demo app is what gets built, so a red cell can be caused by the demo app rather
than the library. The swap changes as little as the target needs, in the repository's root
package.json, the package's own, the demo app's and every other workspace package's (the folders
the root's workspaces names, one-level globs included), so the workspace resolves one React Native
version and one React. A sibling package left on another react or react-test-renderer installs a
second copy of React, and the tests fail with null hooks. The build log (swap.txt) says which case
applied and lists every change.
- Same version. When the demo app already uses the version under test, no package version changes: the check runs the library's own setup (apart from the Gradle wrapper, below).
- Same line, other patch.
react-nativeand the@react-native/*packages pinned in step with it move to the target; nothing else. - Another line.
react-nativebecomes the version under test.react,react-test-rendererand the@react-native-community/clipackages take the versions from the React Native app template for that line. A line without a template (a very new release candidate) takesreactfrom React Native's own peer range and leaves the others.react-domtakes whateverreactis set to: React DOM refuses areactof another version.- Every
@react-native/*package takes the same version as React Native, or the newest release or release candidate on that line when that exact version was never published. Nightlies are never used. - Tests use
@react-native/jest-presetwhen it exists for the line, otherwise React Native's built-inreact-nativepreset (only ajest.presetinpackage.jsonis adjusted). - Expo demo apps move to the Expo SDK that bundles the target line, with every Expo-managed
module at the version that SDK bundles, unless the demo app is already on that SDK, in which
case its Expo versions are kept. Bare demo apps keep their Expo packages. When no SDK bundles
the line (Expo skips lines: 0.82, 0.84, 0.87), the swap stops as a setup failure and the cell
gets no result: an SDK made for another line fails before the library is reached, because its
Gradle plugin and its reanimated refuse the React Native version, and that would be recorded as
the library's incompatibility.
When that moves
react-native-reanimatedto 4 (or the library already pins 4) and the manifest has noreact-native-worklets, the worklets package reanimated 4 needs is added at the version the SDK bundles: a library written for reanimated 3 has no such dependency, and its tests fail to load reanimated without it. - Nothing else changes, and nothing is added apart from the jest preset and
react-native-worklets.
Lockfiles are regenerated. Each check then runs the package's setup.prepare commands; each platform
build runs expo prebuild for Expo demo apps, and iOS runs pod install and builds for the
simulator without code signing, while Android builds one architecture in debug. All of that counts
as the platform's build.
A failed build may come from the demo app and not from the library; the checks panel shows which
check failed, and the build's swap.txt lists every change below that was made. Compared with what
the library published at its tag:
- Package versions: React Native and what moves with it, by the three cases above.
- Jest preset: across lines only. A
@react-native/jest-presetdependency moves to the target line's version like the other@react-native/*packages: a preset for 0.87 mocksreact-native/setup-env, which 0.86 does not have, so every suite fails to start. Whenpackage.jsonsetsjest.preset, the preset is also switched, and the dependency added or removed, as@react-native/jest-presetexists or stops existing for the target line. A preset set injest.config.jskeeps the dependency it names. - Expo SDK: across lines only, and only when the closest SDK is not the one the demo app uses.
react-native-workletsis added where reanimated 4 needs it (above). - Gradle wrapper: always set to the Gradle version of the React Native app template for the
target line, because the React Native version dictates it and a demo app's committed wrapper can
be too old even for its own React Native version. For Expo demo apps it is set right after
expo prebuildgenerates it. A line without a template keeps the demo app's wrapper. - Android SDK:
compileSdkVersion,targetSdkVersionandbuildToolsVersionin a bare demo app's rootandroid/build.gradleare always set to the template's for the target line, for the same reason: the line's Android Gradle plugin can only find the platforms it knows, and a demo app written for a newer line asks for a newer one (compileSdkVersion 37on a plugin tested up to 36 fails with "Failed to find target with hash string 'android-37'" before anything compiles). In the same file,kotlinVersionis raised to the template's when the demo app's is older, so the demo app compiles its Kotlin with the version the line was built with, and never lowered: a newer pin reads the older line fine and may be what the library's own Kotlin code needs. Only values written as literals are changed;minSdkVersionis the library's requirement and is left, as isndkVersion. An Expo demo app'sbuild.gradleis generated byexpo prebuildfrom its SDK, which already matches the line, so it is left alone. A line without a template keeps the demo app's values. - Android Gradle plugin opt-outs:
android.builtInKotlinandandroid.newDslare added to a bare demo app'sandroid/gradle.propertieswhen the template for the target line sets them and the demo app does not. AGP 9, which React Native 0.87 moved to, compiles Kotlin itself by default, and an app that also applies the Kotlin plugin, as every template up to then did, fails with "Failed to apply plugin 'org.jetbrains.kotlin.android'. Cannot add extension with name 'kotlin'"; the 0.87 template keeps the plugin and opts out withandroid.builtInKotlin=false. A demo app written for 0.86 has the plugin but not the opt-out. A value the demo app sets itself, either way, is kept, and a demo app written for 0.87 keeps its opt-outs on 0.86, where the template does not set them. An Expo demo app'sgradle.propertiesis generated byexpo prebuildand left alone. - Default ProGuard file:
getDefaultProguardFile("proguard-android.txt")in a bare demo app'sandroid/app/build.gradleis changed toproguard-android-optimize.txtwhen the template for the target line names that file. AGP 9 refuses the legacy file even with minification off ("getDefaultProguardFile('proguard-android.txt')is no longer supported since it includes-dontoptimize"); every template up to 0.86 named it, and the 0.87 template names the optimize one, which earlier plugins accept too. It is never changed back. ios/.xcode.env.local: removed from bare demo apps that commit it. It is per machine by design and points Xcode at its author's own Node.- The library's own build: the
setup.preparecommands run before the tests and before each native build, because the demo app consumes the compiled library. - The library itself: when the demo app names the library under test with a registry version or
range, that dependency is pointed at the checked-out package (
source.dir) with a relative path:link:for yarn and pnpm,file:for npm. Otherwise the demo app would build the version published on npm, not the tagged code. A dependency that is alreadyworkspace:,link:,file:,portal:or a path is left alone. This applies in all three cases, "same version" included. - CocoaPods. When the swap changed the React Native version,
Podfile.lockandPodsare removed first. They pin the pods from the version the demo app committed, andpod installwill not move them. A same-version check leaves them. The demo app's own way comes first: with aGemfile,bundle installandbundle exec pod install, otherwise plainpod install. If that fails (typically because the repository pins a Ruby version the build machine does not have, in.ruby-version),pod installis tried once more with the machine's own Ruby and CocoaPods, without the Gemfile and without the pin. The machine's own setup is looked up from outside the repository, where no pin applies, and called by its real path, so it does not depend on which Ruby version manager the machine uses: the default Ruby if it has CocoaPods, otherwise the firstpodonPATHthat works there (such as Homebrew's). That fallback setsCOCOAPODS_NO_BUNDLER, because the CocoaPods gem'spodscript otherwise looks for a Gemfile inside the gem and refuses to start. The iOS log says which one was used and why the first failed, andchecks/buildIos.jsonrecords it ("cocoapods": "demo app","machine"or"none"). If both fail, the iOS build is recorded as failed.
Nothing else in the demo app is touched.
Network fetches inside a check are retried so a download failure is not recorded as the library's
failure: the Gradle wrapper's download of Gradle and pod install, bundle install and
expo prebuild get three tries, and Gradle retries its own dependency downloads. Network access
inside xcodebuild and inside a library's own scripts is not retried.
buildIosandbuildAndroidrecordpassedorfailed.testsrecordspassedorfailed, ornonewhen the catalog says the library has no test suite (test.run = "none"). A failing check never stops the others, so the page can show which one failed.- If the setup fails (bad inputs, no such tag, the swap, the Node download, the install), the check fails and leaves no result: the cell keeps its previous one. An Expo demo app on a React Native line that no Expo SDK bundles is such a failure, so those cells stay "not tested yet".
- The pull request step takes the package and both versions from the build's inputs, checked again,
and from
result.jsononly the three outcomes, which must be exactlypassedorfailed(ornonefor tests, and only where the catalog says so). A small, strictly shaped file is required. - Each cell links to its Codemagic build and records when the result was sent.
- The results file is validated with the site's own rules before it is committed; a rejected push is retried on a fresh copy of the base branch, up to five times.
- Logs,
swap.json(which also records the Node version library commands used) and the per-check outcomes are kept as build artifacts next toresult.json.
You need Node 22, corepack, the Android SDK, Java 17, Xcode and CocoaPods. From the repository, with the work and output folders somewhere outside it:
export COMPAT_WORK_DIR=/tmp/rn-green-work COMPAT_OUT_DIR=/tmp/rn-green-out
export COMPAT_PACKAGE=react-native-screenshot-aware COMPAT_LIBRARY_VERSION=2.1.3
export COMPAT_REACT_NATIVE_VERSION=0.87.1 COMPAT_RECORD=false
node scripts/compat/validate-inputs.mjs && node scripts/compat/checkout.mjs &&
node scripts/compat/swap.mjs && node scripts/compat/install.mjs &&
node scripts/compat/check.mjs tests && node scripts/compat/check.mjs android &&
node scripts/compat/check.mjs ios && node scripts/compat/compose-result.mjsCOMPAT_RECORD exists only for local runs; the workflow does not have it. It is on by default, with
the workflow's rules. COMPAT_RECORD=false, as above, checks without a pull request: any exact
versions and a disabled package are accepted, which is how draft settings are tried, and open-pr.mjs
does nothing.
The outcomes are in $COMPAT_OUT_DIR/result.json and the logs in $COMPAT_OUT_DIR/logs/. With
COMPAT_RECORD on and GITHUB_TOKEN set, the same command line can end with
node --experimental-strip-types scripts/compat/open-pr.mjs, which pushes the branch and opens the
pull request on GitHub like the workflow does. COMPAT_REMOTE_URL sends the branch to another git
remote instead; the pull request calls still go to GitHub.
node --experimental-strip-types scripts/compat/validate-data.mjs checks green-packages.toml and
compatibility-data/ with the site's rules.