diff --git a/.github/releases/3.1.0-rc.0.json b/.github/releases/3.1.0-rc.0.json new file mode 100644 index 0000000..fb50f35 --- /dev/null +++ b/.github/releases/3.1.0-rc.0.json @@ -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" + } +} diff --git a/.github/releases/README.md b/.github/releases/README.md new file mode 100644 index 0000000..ca01d26 --- /dev/null +++ b/.github/releases/README.md @@ -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. diff --git a/.github/scripts/release_rc.py b/.github/scripts/release_rc.py new file mode 100644 index 0000000..6224913 --- /dev/null +++ b/.github/scripts/release_rc.py @@ -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() diff --git a/.github/scripts/test_release_rc.py b/.github/scripts/test_release_rc.py new file mode 100644 index 0000000..1f53619 --- /dev/null +++ b/.github/scripts/test_release_rc.py @@ -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() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 400c17b..5fdf202 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,12 +13,25 @@ on: - minor - major - preview + - rc + rc-version: + description: "Reviewed RC version (required for rc)" + type: string + rc-publish: + description: "Publish the verified RC; leave unchecked for a preflight" + type: boolean + default: false permissions: contents: write +concurrency: + group: npm-release + cancel-in-progress: false + jobs: release: + if: inputs.release-type != 'rc' runs-on: ubuntu-latest steps: - name: Checkout Repository @@ -51,3 +64,36 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + + release-rc: + if: inputs.release-type == 'rc' + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-node@v4 + with: + node-version-file: ".nvmrc" + registry-url: https://registry.npmjs.org + - name: Test publication guards + run: python3 -m unittest discover -s .github/scripts -p 'test_release_rc.py' + - name: Verify npm authentication + run: npm whoami --registry=https://registry.npmjs.org + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Verify and publish reviewed RC + run: python3 .github/scripts/release_rc.py + env: + RC_VERSION: ${{ inputs.rc-version }} + RC_PUBLISH: ${{ inputs.rc-publish }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Retain publication receipt + if: always() + uses: actions/upload-artifact@v4 + with: + name: rc-publication-receipt + path: ${{ runner.temp }}/rc-publication/*.json