Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/releases/3.1.0-rc.0.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"name": "react-native-css",
"version": "3.1.0-rc.0",
"sourceCommit": "a5002c598dae6e58904613b07fb5e0ffb55fd732",
"archive": "react-native-css-3.1.0-rc.0.tgz",
"sha256": "a03235e206ef49e48fb960e59eebea14bbb69e623aa168250d8ccca2ff200072",
"integrity": "sha512-/wfPJ8pXiPxvHUs0QcocN3ViV4QJdnXpKukNirZJcJ/MeZKrStSEHm2rBq+KuZm8/2XDl4GfeuaVvJctYGtdgA==",
"consumerDependencies": {
"@babel/core": "7.29.7",
"@expo/metro-config": "57.0.12",
"expo": "57.0.22",
"lightningcss": "1.30.1",
"react": "19.2.3",
"react-native": "0.86.3",
"react-native-reanimated": "4.5.1",
"react-native-worklets": "0.10.1",
"typescript": "6.0.3"
}
}
11 changes: 11 additions & 0 deletions .github/releases/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# RC publication

The existing Release & Publish to NPM workflow accepts `rc`, an exact RC version, and a publication checkbox. An unchecked publication checkbox runs authentication and archive preflight only. Stable and preview releases continue to use release-it.

Each RC has a reviewed JSON descriptor in this directory. It identifies the merged source commit, package version, archive checksums, and Expo consumer dependencies. The exact audited archive is attached to a draft GitHub prerelease whose tag is the package version and whose target is the recorded source commit.

The RC job verifies the descriptor and archive, then publishes the archive under `rc-staging` without rebuilding. An existing npm version is accepted only when its integrity matches. The job downloads the registry archive, verifies both checksums, installs the exact version in a fresh Expo consumer, and runs the engine Node tooling tests against that installation. It checks that only one engine runtime is installed.

After those checks pass, the job advances `rc`, verifies that `latest` is unchanged, and makes the GitHub prerelease public. A failed run retains its publication receipt. Retries accept an already published matching archive and repeat the registry checks before advancing `rc`.

The runner uses the existing `NPM_TOKEN` secret. Authentication is checked before the publication step. Registry authentication failures require fixing the repository's npm publishing credential.
174 changes: 174 additions & 0 deletions .github/scripts/release_rc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
"""Publish a reviewed archive without rebuilding or changing the stable tag."""

import base64
import hashlib
import json
import os
from pathlib import Path
import re
import subprocess
import tarfile
import time
import urllib.error
import urllib.request


REGISTRY = "https://registry.npmjs.org"


def check(condition, message):
if not condition:
raise RuntimeError(message)


def validate_version(version):
check(re.fullmatch(r"\d+\.\d+\.\d+-rc\.\d+", version), "Invalid RC version")


def verify_archive(archive, descriptor):
data = archive.read_bytes()
check(hashlib.sha256(data).hexdigest() == descriptor["sha256"], "Archive SHA256 mismatch")
integrity = "sha512-" + base64.b64encode(hashlib.sha512(data).digest()).decode()
check(integrity == descriptor["integrity"], "Archive integrity mismatch")
with tarfile.open(archive) as tar:
package = json.load(tar.extractfile("package/package.json"))
check(package["name"] == descriptor["name"], "Wrong package")
check(package["version"] == descriptor["version"], "Wrong version")
for field in ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies"):
check(not any(value.startswith(("file:", "link:", "workspace:"))
for value in package.get(field, {}).values()), "Local dependency in archive")


def verify_registry(metadata, descriptor):
check(metadata["name"] == descriptor["name"], "Registry package mismatch")
check(metadata["version"] == descriptor["version"], "Registry version mismatch")
check(metadata["dist"]["integrity"] == descriptor["integrity"], "Published version has different bytes")


def verify_latest(before, after):
check(before.get("latest") == after.get("latest"), "The stable latest tag changed")


def registry_json(route, missing_ok=False, attempts=1):
for attempt in range(attempts):
try:
request = urllib.request.Request(REGISTRY + route, headers={"Cache-Control": "no-cache"})
with urllib.request.urlopen(request, timeout=60) as response:
return json.load(response)
except urllib.error.HTTPError as error:
if error.code != 404:
raise
if attempt + 1 < attempts:
print("Waiting for npm registry visibility...", flush=True)
time.sleep(10)
continue
if missing_ok:
return None
raise


def wait_for_rc_tag(before, version, attempts=31):
for attempt in range(attempts):
after = registry_json("/-/package/react-native-css/dist-tags")
verify_latest(before, after)
if after.get("rc") == version:
return after
if attempt + 1 < attempts:
time.sleep(10)
raise RuntimeError("RC tag is not visible after waiting for the registry")


def run(*args, cwd=None, env=None):
subprocess.run(args, cwd=cwd, env=env, check=True)


def verify_consumer(root, work, descriptor):
consumer = work / "consumer"
consumer.mkdir()
dependencies = {**descriptor["consumerDependencies"], descriptor["name"]: descriptor["version"]}
(consumer / "package.json").write_text(json.dumps({"name": "rc-registry-check", "private": True,
"version": "0.0.0", "dependencies": dependencies}))
public_env = {key: value for key, value in os.environ.items()
if key not in ("GH_TOKEN", "GITHUB_TOKEN", "NPM_TOKEN", "NODE_AUTH_TOKEN")}
public_env["NODE_AUTH_TOKEN"] = ""
run("npm", "install", "--no-audit", "--no-fund", "--registry=" + REGISTRY, cwd=consumer, env=public_env)
lock = json.loads((consumer / "package-lock.json").read_text())
engines = {key: value for key, value in lock["packages"].items()
if key.endswith("node_modules/react-native-css")}
check(list(engines) == ["node_modules/react-native-css"], "Expected one engine runtime")
installed = engines["node_modules/react-native-css"]
check(installed["version"] == descriptor["version"], "Installed version mismatch")
check(installed["integrity"] == descriptor["integrity"], "Installed archive integrity mismatch")
check(installed["resolved"].startswith(REGISTRY + "/react-native-css/-/"), "Engine did not come from npm")
engine = consumer / "node_modules/react-native-css"
tests = consumer / "tests"
tests.mkdir()
for source in (root / "node/__tests__").glob("*.mjs"):
# Only relocate private helper entry paths; keep every assertion unchanged.
content = source.read_text().replace("../../dist/", str(engine / "dist") + "/")
(tests / source.name).write_text(content)
public_env["CSS_COMPONENT_REGISTRY_ROOT"] = str(engine)
public_env["CSS_TYPESCRIPT_SETUP_ENTRY"] = str(engine / "dist/commonjs/metro/typescript.js")
public_env["CSS_LOADER_UNDER_TEST"] = str(engine / "dist/commonjs/compiler/lightningcss-loader.js")
run("node", "--test", *map(str, sorted(tests.glob("*.mjs"))), cwd=consumer, env=public_env)


def main():
version = os.environ["RC_VERSION"]
validate_version(version)
root = Path(__file__).resolve().parents[2]
descriptor = json.loads((root / ".github/releases" / (version + ".json")).read_text())
check(descriptor["name"] == "react-native-css" and descriptor["version"] == version, "Invalid release descriptor")
check(descriptor["archive"] == "react-native-css-" + version + ".tgz", "Invalid archive name")
run("git", "merge-base", "--is-ancestor", descriptor["sourceCommit"], "HEAD", cwd=root)
release = json.loads(subprocess.check_output(
["gh", "release", "view", version, "--json", "isDraft,isPrerelease,targetCommitish"], text=True))
check(release["targetCommitish"] == descriptor["sourceCommit"], "Release targets different source")
check(release["isPrerelease"], "Expected a GitHub prerelease")
work = Path(os.environ["RUNNER_TEMP"]) / "rc-publication"
work.mkdir(exist_ok=True)
run("gh", "release", "download", version, "--pattern", descriptor["archive"], "--dir", str(work))
archive = work / descriptor["archive"]
verify_archive(archive, descriptor)
tag_route = "/-/package/react-native-css/dist-tags"
version_route = "/react-native-css/" + version
before = registry_json(tag_route)
existing = registry_json(version_route, missing_ok=True)
if existing is not None:
verify_registry(existing, descriptor)
receipt = {"descriptor": descriptor, "tagsBefore": before, "stage": "preflight-passed"}
receipt_file = work / "publication.json"

def save(stage):
receipt["stage"] = stage
receipt_file.write_text(json.dumps(receipt, indent=2) + "\n")

save("preflight-passed")
if os.environ.get("RC_PUBLISH") != "true":
print("RC preflight passed; publication was not requested.")
return
if existing is None:
run("npm", "publish", str(archive), "--tag", "rc-staging", "--access", "public",
"--ignore-scripts", "--registry=" + REGISTRY, "--loglevel=warn")
metadata = registry_json(version_route, attempts=31)
verify_registry(metadata, descriptor)
receipt["registry"] = metadata
save("registry-integrity-verified")
downloaded = work / "registry"
downloaded.mkdir()
run("npm", "pack", "react-native-css@" + version, "--ignore-scripts", "--pack-destination",
str(downloaded), "--registry=" + REGISTRY, "--loglevel=warn")
verify_archive(downloaded / descriptor["archive"], descriptor)
verify_consumer(root, work, descriptor)
save("registry-consumer-passed")
verify_latest(before, registry_json(tag_route))
run("npm", "dist-tag", "add", "react-native-css@" + version, "rc", "--registry=" + REGISTRY)
after = wait_for_rc_tag(before, version)
receipt["tagsAfter"] = after
save("rc-published")
run("gh", "release", "edit", version, "--draft=false", "--prerelease", "--latest=false")
save("complete")


if __name__ == "__main__":
main()
134 changes: 134 additions & 0 deletions .github/scripts/test_release_rc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import base64
import hashlib
import io
import json
from pathlib import Path
import tarfile
import tempfile
import unittest
from unittest.mock import patch
from urllib.error import HTTPError

from release_rc import registry_json, validate_version, verify_archive, verify_latest, verify_registry, wait_for_rc_tag


class ReleaseGuards(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.archive = Path(self.temp.name) / "package.tgz"
self.descriptor = {"name": "react-native-css", "version": "3.1.0-rc.0"}

def pack(self, **metadata):
package = {**self.descriptor, **metadata}
data = json.dumps(package).encode()
with tarfile.open(self.archive, "w:gz") as tar:
entry = tarfile.TarInfo("package/package.json")
entry.size = len(data)
tar.addfile(entry, io.BytesIO(data))
archive_bytes = self.archive.read_bytes()
self.descriptor.update({
"sha256": hashlib.sha256(archive_bytes).hexdigest(),
"integrity": "sha512-" + base64.b64encode(hashlib.sha512(archive_bytes).digest()).decode(),
})

def test_only_exact_rc_versions_are_accepted(self):
validate_version("3.1.0-rc.0")
for version in ("3.1.0", "3.1.0-preview.0", "rc", "../../3.1.0-rc.0", "3.1.0-rc.0; npm publish"):
with self.subTest(version=version), self.assertRaises(RuntimeError):
validate_version(version)

def test_reviewed_archive_passes(self):
self.pack()
verify_archive(self.archive, self.descriptor)

def test_changed_archive_is_rejected_before_publication(self):
self.pack()
self.archive.write_bytes(self.archive.read_bytes() + b"unreviewed bytes")
with self.assertRaisesRegex(RuntimeError, "SHA256"):
verify_archive(self.archive, self.descriptor)

def test_mismatched_integrity_is_rejected(self):
self.pack()
self.descriptor["integrity"] = "sha512-other"
with self.assertRaisesRegex(RuntimeError, "integrity"):
verify_archive(self.archive, self.descriptor)

def test_different_package_or_version_is_rejected(self):
for metadata in ({"name": "other-package"}, {"version": "3.1.0"}):
with self.subTest(metadata=metadata):
self.pack(**metadata)
with self.assertRaises(RuntimeError):
verify_archive(self.archive, self.descriptor)

def test_local_dependencies_are_rejected(self):
for field in ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies"):
for dependency in ("file:../engine.tgz", "link:../engine", "workspace:*"):
with self.subTest(field=field, dependency=dependency):
self.pack(**{field: {"engine": dependency}})
with self.assertRaisesRegex(RuntimeError, "Local dependency"):
verify_archive(self.archive, self.descriptor)

def test_retry_accepts_identical_registry_bytes(self):
self.pack()
metadata = {"name": self.descriptor["name"], "version": self.descriptor["version"],
"dist": {"integrity": self.descriptor["integrity"]}}
verify_registry(metadata, self.descriptor)

def test_registry_collision_is_rejected(self):
self.pack()
metadata = {"name": self.descriptor["name"], "version": self.descriptor["version"],
"dist": {"integrity": "sha512-other"}}
with self.assertRaisesRegex(RuntimeError, "different bytes"):
verify_registry(metadata, self.descriptor)

def test_rc_tag_can_advance_without_changing_latest(self):
verify_latest({"latest": "3.0.7"}, {"latest": "3.0.7", "rc": "3.1.0-rc.0"})

def test_stable_tag_change_is_rejected(self):
with self.assertRaisesRegex(RuntimeError, "latest"):
verify_latest({"latest": "3.0.7"}, {"latest": "3.1.0-rc.0"})

def test_new_registry_version_can_become_visible_after_404(self):
missing = HTTPError("https://registry.npmjs.org/example", 404, "Not found", {}, None)
with patch("release_rc.urllib.request.urlopen", side_effect=[missing, io.BytesIO(b'{"version":"3.1.0-rc.0"}')]) as fetch:
with patch("release_rc.time.sleep") as sleep:
self.assertEqual(registry_json("/example", attempts=3)["version"], "3.1.0-rc.0")
self.assertEqual(fetch.call_count, 2)
sleep.assert_called_once_with(10)

def test_missing_version_retry_is_bounded(self):
missing = HTTPError("https://registry.npmjs.org/example", 404, "Not found", {}, None)
with patch("release_rc.urllib.request.urlopen", side_effect=missing) as fetch:
with patch("release_rc.time.sleep") as sleep:
with self.assertRaises(HTTPError):
registry_json("/example", attempts=3)
self.assertEqual(fetch.call_count, 3)
self.assertEqual(sleep.call_count, 2)

def test_authentication_errors_are_not_retried_as_propagation(self):
forbidden = HTTPError("https://registry.npmjs.org/example", 403, "Forbidden", {}, None)
with patch("release_rc.urllib.request.urlopen", side_effect=forbidden):
with patch("release_rc.time.sleep") as sleep:
with self.assertRaises(HTTPError):
registry_json("/example", attempts=3)
sleep.assert_not_called()

def test_rc_tag_can_become_visible_after_stale_response(self):
before = {"latest": "3.0.7"}
after = {**before, "rc": "3.1.0-rc.0"}
with patch("release_rc.registry_json", side_effect=[before, after]):
with patch("release_rc.time.sleep") as sleep:
self.assertEqual(wait_for_rc_tag(before, "3.1.0-rc.0", attempts=3), after)
sleep.assert_called_once_with(10)

def test_rc_polling_never_accepts_a_stable_tag_change(self):
with patch("release_rc.registry_json", return_value={"latest": "3.1.0-rc.0", "rc": "3.1.0-rc.0"}):
with patch("release_rc.time.sleep") as sleep:
with self.assertRaisesRegex(RuntimeError, "latest"):
wait_for_rc_tag({"latest": "3.0.7"}, "3.1.0-rc.0")
sleep.assert_not_called()


if __name__ == "__main__":
unittest.main()
Loading
Loading