From 6b8487a6b1f20103248eb842ca53edc6ac6f2579 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 26 Sep 2026 05:28:34 +0000 Subject: [PATCH 1/3] docs: rewrite README and add AGENTS.md Make the README scannable (version matrix, quick start, tighter API) and add AGENTS.md with repo map, hard rules, and test commands for contributors and coding agents. Co-authored-by: Perry --- AGENTS.md | 160 +++++++++++++++++++ README.md | 464 ++++++++++++++++++++---------------------------------- 2 files changed, 331 insertions(+), 293 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e195e38 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,160 @@ +# AGENTS.md + +Guidance for AI coding agents and human contributors working in this repository. + +## What this is + +`react-native-zip-archive` — a React Native **TurboModule** that zips and unzips files on iOS and Android. Public npm package; JS API is stable across v7 → v9 (native rebuild required when upgrading). + +| Layer | Role | +|-------|------| +| `index.js` / `index.d.ts` | Public JS API + TypeScript types | +| `specs/NativeZipArchive.ts` | Codegen TurboModule spec (source of truth for native method names) | +| `android/src/...` | Android implementation (zip4j) | +| `ios/RNZipArchive.mm` | iOS implementation (SSZipArchive / minizip) | +| `app.plugin.js` | Expo config plugin (passthrough; enables `plugins` entry) | +| `playground-expo/` / `playground-rn/` | Demo apps (`file:..` link to this package) | +| `__tests__/` | Jest tests (native module mocked) | +| `scripts/` | Interop validators, E2E helpers | +| `.maestro/` | Maestro E2E flows | + +## Version matrix (do not invent) + +| React Native | Package | +|--------------|---------| +| < 0.70 | Stay on **v7** (`^7.0.0`) | +| 0.70–0.81 | **v9** — New Arch recommended; old arch works after native rebuild | +| 0.82+ | **v9** — New Arch only (RN ignores opt-out flags) | + +- Current package version: see `package.json`. +- User-facing docs: `README.md`. Upgrade notes: `MIGRATION.md`. Security: `SECURITY.md`. +- Review checklist: `REVIEW.md`. + +## Hard rules + +1. **Spec parity.** Any change to `specs/NativeZipArchive.ts` must be reflected in Android, iOS, `index.js`, and `index.d.ts`. Do not ship one-sided API changes. +2. **Zip Slip.** Unzip must reject entry paths that escape the destination (`ERR_UNSAFE_PATH`). Touching path normalization or extract on either platform requires security scrutiny. Symlink entries must be skipped, not materialized. +3. **Stable error codes.** Reject with the `ERR_*` codes in `ErrorCodes` (`index.js`). Keep codes aligned on both platforms. +4. **No new heavy deps.** Peer range is React ≥ 18 and RN ≥ 0.70. Do not add native or JS dependencies lightly. +5. **`ZipError` is a factory**, not an ES `class` (Metro / `@babel/runtime`). Never introduce `instanceof ZipError` checks in docs or examples. +6. **Do not edit generated / lock noise** unless the task requires it: `node_modules/`, build outputs, playground lock churn without a real dep change. + +## How the JS bridge loads + +```text +TurboModuleRegistry.get('RNZipArchive') → NativeModules.RNZipArchive +``` + +If neither is present, throw `ERR_UNSUPPORTED` with a clear install/link message. + +Options objects (`{ signal }`, `{ entries }`, `{ compressionLevel }`) are JS-side conveniences; natives use the codegen positional methods. Keep overloads in `index.d.ts` in sync with `index.js`. + +## Native conventions + +- **Threading:** Android zip/unzip on a single-thread executor (FIFO). iOS on a background serial queue. Never block the UI/main thread with archive I/O. `cancel()` must not wait behind the operation it should abort. +- **Encryption default:** `'STANDARD'` = ZipCrypto (interop with Node/Java/`unzip`). AES = WinZip-AES; many server tools cannot open AES zips — default to STANDARD for off-device consumers. +- **Charset:** Android may honor non-UTF-8; iOS must reject non-UTF-8 with `ERR_UNSUPPORTED` (except where docs say charset is ignored, e.g. `getUncompressedSize` on iOS). +- **Progress:** Emit monotonic 0→1 with explicit start/end. Shape: `{ progress, filePath }`. Treat cross-platform divergence as a bug. + +## Testing commands + +Run from repo root: + +```bash +npm test # Jest — preferred fast check for JS/API changes +npm run test:interop # Node unzipper + Java ZipInputStream on fixtures/ +npm run lint # ESLint on index.js +``` + +When changing the public API: + +1. Update `__mocks__/react-native.js` if the native surface changes. +2. Extend `__tests__/api.test.js` (and related) for new overloads / error paths. +3. Prefer `npm test` before claiming done; add interop checks if zip format/encryption defaults change. + +E2E (device/simulator, heavy): + +```bash +npm run test:e2e:expo # Maestro vs playground-expo +npm run test:e2e:rn # Maestro vs playground-rn +``` + +See `e2e/README.md`. Skip E2E unless the change is native behavior that Jest cannot cover. + +Old-architecture compile proof (CI): `.github/workflows/old-arch.yml` on RN **0.81.6**. Not device Maestro. + +## Playgrounds + +| Directory | Purpose | +|-----------|---------| +| `playground-expo/` | Expo SDK 55 / New Arch — config plugin + `expo-file-system` usage | +| `playground-rn/` | Bare RN 0.83.9 / New Arch | + +Both depend on `"react-native-zip-archive": "file:.."`. After changing native code, rebuild the playground (Metro reload alone is not enough). + +Treat playground edits as optional unless the task is demo/E2E related. Prefer keeping playgrounds compiling when you change the public API. + +## Docs ownership + +| File | Audience | +|------|----------| +| `README.md` | Install, quick start, API reference | +| `MIGRATION.md` | Version upgrades (v7→v9, minor notes) | +| `SECURITY.md` | Supported versions, vuln reporting, Zip Slip scope | +| `REVIEW.md` | PR review focus (parity, security, threading) | +| `AGENTS.md` | This file — agent/contributor working map | +| `CHANGELOG.md` | Release history | + +Keep README examples accurate to `index.d.ts`. Prefer linking here or to `MIGRATION.md` for deep architecture detail instead of duplicating long matrices in the README. + +## CI workflows (`.github/workflows/`) + +| Workflow | What it covers | +|----------|----------------| +| `android-build.yml` / `ios-build.yml` | Native builds | +| `e2e.yml` | Maestro E2E | +| `old-arch.yml` | RN 0.81.6 paper/old-arch compile | +| `zip-interop.yml` | Fixture unzip interop | +| `publish.yml` | npm publish | +| `minor-discussion.yml` | Announcement Discussion for minors | + +## Common tasks + +### Add or change a public API + +1. Spec (`specs/NativeZipArchive.ts`) if native signature changes. +2. Android + iOS implementations. +3. `index.js` wrappers (options / AbortSignal / validation). +4. `index.d.ts` overloads. +5. Jest mocks + tests. +6. README API section (short example). +7. `MIGRATION.md` if behavior/default changes for existing callers. + +### Encryption / interop change + +- Prefer STANDARD for defaults that leave the device. +- Update `fixtures/interop/` and `npm run test:interop` expectations. +- Document in `MIGRATION.md` if defaults flip. + +### Security-sensitive extract change + +- Mirror Android and iOS validation. +- Reject traversal with `ERR_UNSAFE_PATH`. +- Skip symlinks on extract. +- Note supported-version impact in `SECURITY.md` if shipping a fix. + +## Do not + +- Stay on or recommend v7 for RN ≥ 0.70 (except RN < 0.70). +- Claim Expo Go support. +- Force-push `master` or rewrite published release tags. +- Add purple “AI slop” marketing to docs; keep README factual and scannable. +- Estimate calendar time in planning — describe technical scope instead. + +## Quick orientation checklist + +- [ ] Read `package.json` version and peer deps. +- [ ] Skim `specs/NativeZipArchive.ts` + `index.d.ts` for the real API. +- [ ] For native bugs: find the path in `ios/RNZipArchive.mm` and `android/src/main/java/com/rnziparchive/`. +- [ ] Run `npm test` after JS changes; `npm run test:interop` after format/crypto changes. +- [ ] Update docs only for the surfaces you changed (avoid drive-by README rewrites unless asked). diff --git a/README.md b/README.md index f030db7..2a81d1f 100644 --- a/README.md +++ b/README.md @@ -1,82 +1,45 @@ -# React Native Zip Archive [![npm](https://img.shields.io/npm/v/react-native-zip-archive.svg)](https://www.npmjs.com/package/react-native-zip-archive) [![npm downloads](https://img.shields.io/npm/dw/react-native-zip-archive.svg)](https://www.npmjs.com/package/react-native-zip-archive) [![TypeScript](https://img.shields.io/badge/TypeScript-types-3178C6?logo=typescript&logoColor=white)](./index.d.ts) [![React Native New Architecture](https://img.shields.io/badge/React%20Native-New%20Architecture%20(TurboModules)-61dafb)](https://reactnative.dev/docs/new-architecture-intro) +# React Native Zip Archive -Zip archive utility for React Native. +[![npm](https://img.shields.io/npm/v/react-native-zip-archive.svg)](https://www.npmjs.com/package/react-native-zip-archive) +[![npm downloads](https://img.shields.io/npm/dw/react-native-zip-archive.svg)](https://www.npmjs.com/package/react-native-zip-archive) +[![TypeScript](https://img.shields.io/badge/TypeScript-types-3178C6?logo=typescript&logoColor=white)](./index.d.ts) +[![React Native New Architecture](https://img.shields.io/badge/React%20Native-New%20Architecture%20(TurboModules)-61dafb)](https://reactnative.dev/docs/new-architecture-intro) -> **v9** is for React Native ≥ 0.70. New Architecture is recommended. Stay on v7 only for RN **< 0.70**. -> -> | Your React Native | Install | -> |-------------------|---------| -> | **< 0.70** | `npm install react-native-zip-archive@^7.0.0` | -> | **0.70–0.81** | latest v9 (old architecture works; native rebuild required) | -> | **0.82+** | latest v9 (New Architecture only — RN ignores the opt-out flags) | -> -> **iOS:** Version 7.0.0+ requires a deployment target of iOS 15.5+ to comply with App Store privacy policy. +Native zip and unzip for React Native and Expo (iOS & Android). Password protection, progress events, selective extract, and `AbortSignal` cancellation. -## Requirements - -| Platform | Minimum Version | -|----------|-----------------| -| React Native | >= 0.70.0 | -| React | >= 18.0.0 | -| iOS | >= 15.5 | -| Android | >= API 23 (Android 6.0) | - -## Old architecture (RN 0.70–0.81) - -Do not stay on v7 for old architecture on RN 0.70+. Install latest v9 and rebuild native. - -| Surface | How v9 loads when New Architecture is off | -|---------|-------------------------------------------| -| JS | `TurboModuleRegistry.get('RNZipArchive')`, then `NativeModules.RNZipArchive` | -| Android | `isTurboModule` follows `BuildConfig.IS_NEW_ARCHITECTURE_ENABLED`; paper specs compile when new arch is off | -| iOS | `RCT_EXPORT_MODULE` always; `getTurboModule` is `#ifdef RCT_NEW_ARCH_ENABLED` | - -| RN | Android `newArchEnabled=false` | iOS `RCT_NEW_ARCH_ENABLED=0` | Evidence | -|----|--------------------------------|------------------------------|----------| -| **0.82+** ([playground-rn](./playground-rn/) 0.83.9) | N/A — flag ignored | N/A — flag ignored | [RN 0.82](https://reactnative.dev/blog/2025/10/08/react-native-0.82); zip/unzip Maestro on New Arch (`e2e.yml`) | -| **0.81.6** (last opt-out) | compile + `IS_NEW_ARCHITECTURE_ENABLED=false` | compile + Legacy Architecture | `.github/workflows/old-arch.yml` (not device Maestro) | -| **0.73–0.80** | same native paths as 0.81 | same | inferred; not separately built | +## Which version? -Reproduce the 0.81 compile (same as CI): +| React Native | Install | +|--------------|---------| +| **< 0.70** | `npm install react-native-zip-archive@^7.0.0` | +| **0.70–0.81** | Latest **v9** (old architecture works; rebuild native) | +| **0.82+** | Latest **v9** (New Architecture only) | -```bash -npx @react-native-community/cli@15.1.3 init RnzaOldArch --version 0.81.6 --pm npm --skip-git-init -cd RnzaOldArch && npm install /path/to/react-native-zip-archive -# Android: set newArchEnabled=false in android/gradle.properties, then assembleRelease -# iOS: set platform :ios, '15.5' in the Podfile, then RCT_NEW_ARCH_ENABLED=0 pod install -``` +**iOS:** v7+ requires deployment target **iOS 15.5+**. -## Comparison +New Architecture is recommended. See [MIGRATION.md](./MIGRATION.md) for upgrades from v7. -| | This library | JSZip in React Native | Nitro (`react-native-nitro-unzip` / `react-native-nitro-archive`) | -|---|---|---|---| -| Zip / unzip | Native iOS + Android | Pure JS (not a native unzip) | Native via Nitro | -| Password-protected zip | Yes | Fine for small in-memory archives | Check those packages | -| Expo | Development builds / EAS (not Expo Go) | Can run in Expo Go | Native — needs a development build | -| Extra native dependency | None beyond this package | None | `react-native-nitro-modules` | -| Large files | Native I/O | Memory-heavy | Speed / extra-format claims | -| Install base | Production RN apps | Very widely used as JS | Much smaller today | +## Requirements -Use this library for native zip/unzip on device. Use JSZip when you only need small archives in JS. Nitro may fit if you want additional archive formats and accept the extra Nitro dependency and smaller install base. +| | Minimum | +|--|---------| +| React Native | ≥ 0.70 | +| React | ≥ 18 | +| iOS | ≥ 15.5 | +| Android | API 23+ | ## Installation -### React Native (bare) +### React Native ```bash npm install react-native-zip-archive -``` - -**iOS:** -```bash cd ios && pod install ``` -New Architecture is recommended. See [MIGRATION.md](./MIGRATION.md). - ### Expo -Works in **Expo development builds / EAS**. Does **not** work in Expo Go (this package includes custom native code). +Works in **development builds / EAS** only — not Expo Go (custom native code). ```bash npx expo install react-native-zip-archive @@ -92,361 +55,276 @@ Add the config plugin in `app.json`: } ``` -See [playground-expo](./playground-expo/) for a working Expo Development Build example. +See [playground-expo](./playground-expo/) for a working example. -## Usage +## Quick start ```js import { zip, - zipWithPassword, unzip, + zipWithPassword, unzipWithPassword, listContents, - unzipAssets, - cancel, subscribe, - isPasswordProtected, - getUncompressedSize, + cancel, ErrorCodes, - ZipError, - DEFAULT_COMPRESSION, - NO_COMPRESSION, - BEST_SPEED, - BEST_COMPRESSION } from 'react-native-zip-archive' -``` - -**Bare React Native** — [react-native-fs](https://github.com/johanneslumpe/react-native-fs): -```js +// Paths: use react-native-fs (bare) or expo-file-system/legacy (Expo) import { DocumentDirectoryPath } from 'react-native-fs' -``` +// Expo: const DocumentDirectoryPath = FileSystem.documentDirectory -**Expo** — [playground-expo](./playground-expo/) uses `expo-file-system/legacy`: +const archive = `${DocumentDirectoryPath}/bundle.zip` +const outDir = `${DocumentDirectoryPath}/out` -```js -import * as FileSystem from 'expo-file-system/legacy' +// Zip a folder +await zip(DocumentDirectoryPath, archive) -const DocumentDirectoryPath = FileSystem.documentDirectory -``` +// Unzip +await unzip(archive, outDir) -List, extract a subset, and abort with `AbortSignal`: +// Password +await zipWithPassword(DocumentDirectoryPath, archive, 'secret', 'STANDARD') +await unzipWithPassword(archive, outDir, 'secret') -```js +// List + selective extract + AbortSignal const controller = new AbortController() - -const entries = await listContents(`${DocumentDirectoryPath}/bundle.zip`) +const entries = await listContents(archive) const assets = entries - .filter((entry) => !entry.isDirectory && entry.path.startsWith('assets/')) - .map((entry) => entry.path) - -await unzip(`${DocumentDirectoryPath}/bundle.zip`, `${DocumentDirectoryPath}/out`, { - entries: assets, - signal: controller.signal, -}) + .filter((e) => !e.isDirectory && e.path.startsWith('assets/')) + .map((e) => e.path) -// controller.abort() → rejects with ZipError code ERR_CANCELLED +await unzip(archive, outDir, { entries: assets, signal: controller.signal }) +// controller.abort() → rejects with ZipError code ERR_CANCELLED ``` -`zip` / `zipWithPassword` / `unzipAssets` accept the same `{ signal }` option. `cancel()` still aborts the in-flight native operation. - -## API - -### `zip(source: string | string[], target: string, compressionLevelOrOptions?: number | { compressionLevel?: number, signal?: AbortSignal }): Promise` - -Zip a folder (string) or an array of files to the target path. - -- To zip a single file, pass it as an array: `zip([file], target)`. -- Array items may also be directories: their contents are added recursively with entry paths relative to the listed directory (the directory's own name is not included). This behaves the same on Android and iOS. Empty directories are preserved on both platforms. -- `compressionLevel` applies on both platforms for folder and file-array sources. -- Or pass an options object: `zip(source, target, { compressionLevel: BEST_SPEED, signal })`. - -**Compression Level Constants:** -- `DEFAULT_COMPRESSION` (-1) -- `NO_COMPRESSION` (0) -- `BEST_SPEED` (1) -- `BEST_COMPRESSION` (9) +Progress and cancel: ```js -const sourcePath = DocumentDirectoryPath -const targetPath = `${DocumentDirectoryPath}/myFile.zip` +const sub = subscribe(({ progress, filePath }) => { + console.log(progress, filePath) // progress: 0…1 +}) -zip(sourcePath, targetPath) - .then((path) => console.log(`zip completed at ${path}`)) - .catch((error) => console.error(error)) -``` +await unzip(archive, outDir) +sub.remove() -### `zipWithPassword(source: string | string[], target: string, password: string, encryptionType?: string, compressionLevel?: number): Promise` +// Or abort the in-flight native op +await cancel() // rejects with ErrorCodes.CANCELLED +``` -Zip with password protection. +## API -- To zip a single file, pass it as an array: `zipWithPassword([file], target, password)`. -- Array items may also be directories: their contents are added recursively with entry paths relative to the listed directory (the directory's own name is not included). This behaves the same on Android and iOS. Empty directories are preserved on both platforms. -- `compressionLevel` applies on both platforms for folder and file-array sources. +### `zip(source, target, compressionLevelOrOptions?)` -**Encryption Types:** -- `'STANDARD'` — Traditional ZIP encryption / ZipCrypto (default). This is **not** PKWARE Strong Encryption. On Android this writes zip4j `ZIP_STANDARD` so iOS and common unzip tools can decrypt the archive. -- `'AES-128'` — AES 128-bit -- `'AES-256'` — AES 256-bit +Zip a folder (`string`) or files/folders (`string[]`) to `target`. -> **iOS:** Both AES-128 and AES-256 use AES-256 internally. File arrays honor `encryptionType` the same as folders. The default is ZipCrypto (`'STANDARD'`), including when the 4th argument is omitted — file arrays previously always wrote WinZip-AES. Pass `'AES-128'` or `'AES-256'` if you need AES. Prefer `'STANDARD'` when the archive will be unzipped by Node, Java, or other non-WinZip tools. +- Single file: `zip([file], target)`. +- Array items may be directories; contents are added recursively (entry paths relative to that directory; empty dirs preserved). +- Third arg: compression level (`0`–`9`, or constants below) or `{ compressionLevel, signal }`. ```js -const sourcePath = DocumentDirectoryPath -const targetPath = `${DocumentDirectoryPath}/myFile.zip` +import { BEST_SPEED } from 'react-native-zip-archive' -zipWithPassword(sourcePath, targetPath, 'password', 'STANDARD') - .then((path) => console.log(`zip completed at ${path}`)) - .catch((error) => console.error(error)) +await zip(sourceDir, targetZip) +await zip([fileA, fileB], targetZip, BEST_SPEED) +await zip(sourceDir, targetZip, { compressionLevel: BEST_SPEED, signal }) ``` -### `unzip(source: string, target: string, charset?: string | string[], entries?: string[]): Promise` +**Compression constants:** `DEFAULT_COMPRESSION` (-1), `NO_COMPRESSION` (0), `BEST_SPEED` (1), `BEST_COMPRESSION` (9). -Unzip from source to target. Pass `entries` to extract only those paths; directory names match that entry and all nested children (e.g. `'docs'` extracts `docs/` and `docs/readme.md`). +### `zipWithPassword(source, target, password, encryptionTypeOrOptions?, compressionLevel?)` -You can pass entries as the third argument when using the default charset: +Same sources as `zip`, with a password. -```js -unzip(sourcePath, targetPath, ['readme.md', 'docs']) -``` +**Encryption types:** -Or with an explicit charset: - -```js -unzip(sourcePath, targetPath, 'UTF-8', ['readme.md', 'docs']) -``` +| Value | Meaning | +|-------|---------| +| `'STANDARD'` (default) | ZipCrypto — readable by Node, Java, stock `unzip` | +| `'AES-128'` / `'AES-256'` | WinZip-AES (stronger; many server tools cannot open) | -Or with `AbortSignal` / selective extract as an options object: +On iOS, both AES options use AES-256 internally. Prefer `'STANDARD'` when archives will be unzipped off-device. ```js -unzip(sourcePath, targetPath, { entries: ['readme.md', 'docs'], signal }) +await zipWithPassword(sourceDir, targetZip, 'password', 'STANDARD') +await zipWithPassword(sourceDir, targetZip, 'password', { + encryptionMethod: 'AES-256', + compressionLevel: BEST_COMPRESSION, + signal, +}) ``` -> The `charset` parameter defaults to `UTF-8`. On Android, other charsets are supported. On iOS, non-UTF-8 values reject with `ERR_UNSUPPORTED`. +### `unzip(source, target, charsetOrEntriesOrOptions?, entries?)` -```js -const sourcePath = `${DocumentDirectoryPath}/myFile.zip` -const targetPath = DocumentDirectoryPath +Extract an archive. Optional `entries` extracts only those paths (directories include nested children). -unzip(sourcePath, targetPath, 'UTF-8') - .then((path) => console.log(`unzip completed at ${path}`)) - .catch((error) => console.error(error)) +```js +await unzip(source, target) +await unzip(source, target, 'UTF-8') +await unzip(source, target, ['readme.md', 'docs']) +await unzip(source, target, 'UTF-8', ['readme.md']) +await unzip(source, target, { entries: ['readme.md'], signal }) ``` -### `unzipWithPassword(source: string, target: string, password: string, entries?: string[]): Promise` +Charset defaults to `UTF-8`. On iOS, non-UTF-8 values reject with `ERR_UNSUPPORTED`. -Unzip a password-protected archive. Pass `entries` to extract only those paths. +### `unzipWithPassword(source, target, password, entriesOrOptions?)` ```js -unzipWithPassword(sourcePath, targetPath, 'password') - .then((path) => console.log(`unzip completed at ${path}`)) - .catch((error) => console.error(error)) - -unzipWithPassword(sourcePath, targetPath, 'password', ['secret.txt']) - .then((path) => console.log(`selective unzip completed at ${path}`)) - .catch((error) => console.error(error)) +await unzipWithPassword(source, target, 'password') +await unzipWithPassword(source, target, 'password', ['secret.txt']) +await unzipWithPassword(source, target, 'password', { entries: ['secret.txt'], signal }) ``` -### `listContents(source: string, charset?: string): Promise` - -List archive entries without extracting. +### `listContents(source, charset?)` → `Promise` ```ts type ZipEntry = { path: string - size: number // uncompressed size in bytes + size: number // uncompressed bytes compressedSize: number isDirectory: boolean isEncrypted: boolean } ``` -> The `charset` parameter defaults to `UTF-8`. On Android, other charsets are supported. On iOS, non-UTF-8 values reject with `ERR_UNSUPPORTED`. - -```js -listContents(sourcePath) - .then((entries) => { - entries.forEach((entry) => { - console.log(entry.path, entry.size, entry.isDirectory) - }) - }) - .catch((error) => console.error(error)) -``` - -### `unzipAssets(assetPath: string, target: string): Promise` +### `unzipAssets(assetPath, target, options?)` -Unzip a bundled archive. +Unzip a **bundled** archive (relative path only — not an absolute filesystem path). -- **Android:** relative path inside the APK `assets/` folder (also accepts `content://` URIs). -- **iOS:** relative path inside the main app bundle (e.g. a file copied with Xcode “Copy Bundle Resources”). - -Do not pass an absolute filesystem path. +- **Android:** path under APK `assets/` (also accepts `content://` URIs) +- **iOS:** path in the main app bundle ```js -unzipAssets('./myFile.zip', DocumentDirectoryPath) - .then((path) => console.log(`unzip completed at ${path}`)) - .catch((error) => console.error(error)) +await unzipAssets('./myFile.zip', DocumentDirectoryPath) +await unzipAssets('./myFile.zip', DocumentDirectoryPath, { signal }) ``` -Optional `{ signal }` as the third argument. - -### `getUncompressedSize(source: string, charset?: string): Promise` +### `getUncompressedSize(source, charset?)` → `Promise` -Returns the total uncompressed size of all files in the zip archive (in bytes). +Total uncompressed size in bytes. Charset is Android-only; iOS ignores it. -> The `charset` parameter is only supported on Android. On iOS it is ignored. +### `isPasswordProtected(source)` → `Promise` -```js -getUncompressedSize(sourcePath) - .then((size) => console.log(`Uncompressed size: ${size} bytes`)) - .catch((error) => console.error(error)) -``` +### `cancel()` → `Promise` -### `cancel(): Promise` +Best-effort abort of the in-flight operation. The active promise rejects with `ErrorCodes.CANCELLED` (`ERR_CANCELLED`). -Cancel the in-flight zip/unzip operation (best-effort). The active operation's promise rejects with `ErrorCodes.CANCELLED` (`ERR_CANCELLED`). +Operations are serialized (Android single-thread executor / iOS serial queue). Concurrent calls queue FIFO; `cancel()` is not blocked behind in-flight work. -Zip/unzip work is serialized. Android runs operations on a **single-thread executor**; concurrent calls queue FIFO and do not run in parallel. iOS uses a background serial queue similarly, so `cancel()` is not blocked behind the operation it is meant to stop. +### `subscribe(callback)` → `{ remove() }` ```js -const unzipPromise = unzip(sourcePath, targetPath) -cancel() -unzipPromise.catch((error) => { - if (error.code === ErrorCodes.CANCELLED) { - console.log('unzip cancelled') - } -}) +subscribe(({ progress, filePath }) => { /* progress 0…1 */ }) ``` +- Event is **global** — match `filePath` to your operation, then call `.remove()`. +- `unzip` / `unzipWithPassword`: byte-weighted after each entry. +- `zip` / `zipWithPassword`: per-file. +- `unzipAssets` (Android): approximate vs compressed size. + ### Error codes -Native rejections use stable `error.code` values on both platforms: +Stable `error.code` on both platforms (also on `ErrorCodes`): | Code | When | |------|------| | `ERR_FILE_NOT_FOUND` | Source missing | | `ERR_INVALID_PATH` | Bad / null path | | `ERR_INVALID_ARGS` | Empty password, empty entries, etc. | -| `ERR_WRONG_PASSWORD` | Password decrypt failed | -| `ERR_NOT_PASSWORD_PROTECTED` | Password API used on a plain archive | -| `ERR_CORRUPT_ARCHIVE` | Not a zip / truncated / unreadable | +| `ERR_WRONG_PASSWORD` | Decrypt failed | +| `ERR_NOT_PASSWORD_PROTECTED` | Password API on a plain archive | +| `ERR_CORRUPT_ARCHIVE` | Not a zip / truncated | | `ERR_UNSAFE_PATH` | Zip Slip / path traversal | -| `ERR_CANCELLED` | `cancel()` interrupted the operation | -| `ERR_ZIP` / `ERR_UNZIP` | Generic zip/unzip failure | -| `ERR_UNSUPPORTED` | API not available on this platform | +| `ERR_CANCELLED` | `cancel()` or `AbortSignal` | +| `ERR_ZIP` / `ERR_UNZIP` | Generic failure | +| `ERR_UNSUPPORTED` | Not available on this platform | -Also exported as the `ErrorCodes` constant map. +`ZipError` is a factory (not an ES class). Check `error.code`; do not use `instanceof`. -### `subscribe(callback: ({ progress: number, filePath: string }) => void): EmitterSubscription` +## Platform support -Subscribe to progress events. Useful for showing a progress bar. +| Feature | iOS | Android | +|---------|:---:|:-------:| +| `zip` / `zipWithPassword` | ✅ | ✅ | +| `unzip` / `unzipWithPassword` (+ selective `entries`) | ✅ | ✅ | +| `listContents` | ✅ | ✅ | +| `unzipAssets` | ✅ | ✅ | +| `cancel` / `AbortSignal` | ✅ | ✅ | +| `isPasswordProtected` / `getUncompressedSize` | ✅ | ✅ | +| Progress events | ✅ | ✅ | -- `progress` — value from 0 to 1 (1 = completed) -- `filePath` — the zip file path (on iOS, the entry being processed for unzip operations; empty for zip operations) +**Notes** -Progress is reported monotonically from 0 to 1, with explicit 0% and 100% events at the start and end of each operation. The granularity depends on the operation: +- **Encryption:** Prefer `'STANDARD'` for server-side unzip. AES archives often fail with Node `unzipper` / Java `ZipInputStream`. +- **Charset:** Android supports custom charsets; iOS is UTF-8 only (`ERR_UNSUPPORTED` otherwise). +- **Paths:** Decode URL-encoded paths (`decodeURIComponent`) before passing them — `%20` has been mistaken for corrupt archives (#333). +- **Interop check:** `node scripts/validate-zip-header.js /path/to/archive.zip` and `npm run test:interop`. -- `unzip` / `unzipWithPassword` — byte-weighted: progress reflects uncompressed bytes extracted so far, updated after each entry completes. -- `zip` / `zipWithPassword` — per-file: progress reflects the number of files compressed so far. -- `unzipAssets` (Android only) — approximate: compares bytes read to the compressed archive size. +## Old architecture (RN 0.70–0.81) -> The event is global — check `filePath` in your callback to ensure it matches the operation you care about. Remember to call `.remove()` on the returned subscription when done. +Do not stay on v7 for old architecture on RN 0.70+. Install latest v9 and rebuild native. -```js -import { useEffect } from 'react' - -useEffect(() => { - const sub = subscribe(({ progress, filePath }) => { - console.log(`progress: ${progress}, file: ${filePath}`) - }) - return () => sub.remove() -}, []) -``` +v9 loads via `TurboModuleRegistry` first, then `NativeModules.RNZipArchive`. On RN 0.82+, the old-architecture opt-out flags are ignored. -## Platform Support - -| Feature | iOS | Android | Notes | -|---------|-----|---------|-------| -| `zip` (folder) | ✅ | ✅ | `compressionLevel` 0–9 | -| `zip` (files array) | ✅ | ✅ | `compressionLevel` applies on both platforms | -| `zipWithPassword` (folder) | ✅ | ✅ | Prefer `STANDARD` for server unzip | -| `zipWithPassword` (files array) | ✅ | ✅ | iOS honors `STANDARD` vs AES; `compressionLevel` applies | -| `unzip` | ✅ | ✅ | Optional `entries`; non-UTF-8 charset → `ERR_UNSUPPORTED` on iOS | -| `unzipWithPassword` | ✅ | ✅ | Optional `entries` for selective extract | -| `listContents` | ✅ | ✅ | Non-UTF-8 charset → `ERR_UNSUPPORTED` on iOS | -| `unzipAssets` | ✅ | ✅ | Android `assets/` (+ `content://`); iOS main bundle | -| `cancel` | ✅ | ✅ | Best-effort mid-operation abort | -| `isPasswordProtected` | ✅ | ✅ | — | -| `getUncompressedSize` | ✅ | ✅ | Non-UTF-8 charset → `ERR_UNSUPPORTED` on iOS | -| Progress Events | ✅ | ✅ | File path empty on iOS for zip | - -### Cross-Platform Notes - -- **Compression levels:** Android and iOS apply `compressionLevel` (0–9) for folder and file-array `zip` / `zipWithPassword`. -- **Encryption:** Android supports AES-128, AES-256, and Standard ZIP encryption for all operations. On iOS, pass `'STANDARD'` (default) for ZipCrypto archives that Node `unzipper` / Java `ZipInputStream` can read; `'AES-128'` / `'AES-256'` produce WinZip-AES archives that many server tools cannot open. -- **Charset:** Android supports custom charsets (default UTF-8). iOS accepts only UTF-8; other values reject with `ERR_UNSUPPORTED`. -- **unzipAssets:** Android reads `assets/` (and `content://`). iOS reads from the main app bundle using the same relative path. -- **Empty directories:** Preserved when zipping directory contents via a files/folders array on both platforms. -- **Concurrent operations:** Android zip/unzip run on a single-thread executor; concurrent calls queue FIFO and do not run in parallel. iOS uses a background serial queue similarly (so `cancel()` is not blocked behind in-flight work). - -### Server-side unzip interoperability - -Plain (non-AES) zips created on iOS and Android are intended to open with common server unzippers (`unzip`, Node `unzipper`, Java `ZipInputStream`). Practical tips: - -- Prefer `zip(...)` or `zipWithPassword(..., 'STANDARD')` when the archive will be extracted off-device. -- Avoid AES password zips if the consumer is stock Java/`unzipper` — use `'STANDARD'` instead. -- Decode URL-encoded paths (`decodeURIComponent`) before passing them in; `%20` in paths has been mistaken for corrupt archives (#333). -- After upgrading, you can sanity-check a produced file with: +CI compile proof for RN 0.81.6: [`.github/workflows/old-arch.yml`](./.github/workflows/old-arch.yml). Details for agents and contributors: [AGENTS.md](./AGENTS.md). -```bash -node scripts/validate-zip-header.js /path/to/archive.zip -``` +## Playgrounds -CI and the npm publish workflow also extract a committed non-password fixture with Node `unzipper` and Java `ZipInputStream` (`npm run test:interop`). A WinZip-AES archive fails that gate — that was the #333 / #323 class of iOS default-AES zips. +| App | Stack | +|-----|--------| +| [playground-expo](./playground-expo/) | Expo SDK 55, Expo Router, New Architecture | +| [playground-rn](./playground-rn/) | Bare RN 0.83.9, New Architecture | -## Expo +Both consume the library via `file:..` and include Maestro E2E flows under [`.maestro/`](./.maestro/). -Works in Expo development builds / EAS only — not Expo Go. Install and plugin setup are under [Installation](#installation). See [playground-expo](./playground-expo/) for a working example. +## Comparison -## Playground +| | This library | JSZip | Nitro unzip/archive | +|--|--------------|-------|---------------------| +| Zip / unzip | Native iOS + Android | Pure JS | Native via Nitro | +| Password zips | Yes | Small in-memory only | Check those packages | +| Expo Go | No (dev build) | Yes | No (dev build) | +| Extra native deps | None | None | `react-native-nitro-modules` | +| Large files | Native I/O | Memory-heavy | Varies | -Two fully-featured playground apps are included to demonstrate every API method: +Use this library for on-device native zip/unzip. Use JSZip for small in-JS archives. -- **[playground-expo](./playground-expo/)** — Expo SDK 55 with Expo Router (New Architecture) -- **[playground-rn](./playground-rn/)** — Bare React Native 0.83.9 (New Architecture) +## Testing -Both apps consume the local library via `file:..` and include Maestro E2E tests. +```bash +npm test # Jest (JS layer + mocks) +npm run test:interop # Node/Java unzip of committed fixtures +``` + +E2E (Maestro): see [e2e/README.md](./e2e/README.md). ## Migrating -Coming from v7? Start with [Upgrade from v7](./MIGRATION.md#upgrade-from-v7). See [MIGRATION.md](./MIGRATION.md) for v7 → v8, v8 → v9.0, and v9.2–v9.4 notes. +Coming from v7? Start with [Upgrade from v7](./MIGRATION.md#upgrade-from-v7). Full notes: [MIGRATION.md](./MIGRATION.md). ## Security -See [SECURITY.md](./SECURITY.md) for supported versions and how to report vulnerabilities. - -## Testing - -```bash -npm test -``` +Supported versions and reporting: [SECURITY.md](./SECURITY.md). ## Contributing -See the [playground apps](#playground) for testing and contribution reference. +- Use the [playground apps](#playgrounds) to exercise changes. +- Agent / contributor map: [AGENTS.md](./AGENTS.md). +- Review focus areas: [REVIEW.md](./REVIEW.md). ### Minor releases -Each **minor** (`vX.Y.0`) gets one GitHub Discussion in **Announcements** (why to upgrade, 3–5 bullets) linked from that minor’s GitHub Release. +Each minor (`vX.Y.0`) gets one GitHub Discussion in **Announcements**. 1. Add `.github/announcements/vX.Y.md` with an H1 title and `` -2. Merge to `master` — [minor-discussion.yml](./.github/workflows/minor-discussion.yml) opens or reuses the Discussion and edits the release notes +2. Merge to `master` — [minor-discussion.yml](./.github/workflows/minor-discussion.yml) opens or reuses the Discussion -## Related Projects +## Related -- [ZipArchive](https://github.com/ZipArchive/ZipArchive) -- [zip4j](https://github.com/srikanth-lingala/zip4j) +- [ZipArchive](https://github.com/ZipArchive/ZipArchive) (iOS) +- [zip4j](https://github.com/srikanth-lingala/zip4j) (Android) --- From 2e3d3272e14286de45cabe22becbdef1610f44d1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 26 Sep 2026 05:35:19 +0000 Subject: [PATCH 2/3] =?UTF-8?q?chore:=20enforce=20README=20=E2=86=94=20AGE?= =?UTF-8?q?NTS.md=20sync=20via=20portable=20checks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopt the agents.md open standard: README for humans, AGENTS.md for agents (CLAUDE.md points at AGENTS.md). Add scripts/check-docs-sync.sh for shared-fact + change-pairing checks, wire it through npm, pre-commit, and GitHub Actions so enforcement is not Cursor-specific. Co-authored-by: Perry --- .github/workflows/docs-sync.yml | 33 ++++++ .pre-commit-config.yaml | 12 ++ AGENTS.md | 36 ++++-- CLAUDE.md | 5 + README.md | 12 +- __tests__/package-metadata.test.js | 4 + package.json | 1 + scripts/check-docs-sync.sh | 177 +++++++++++++++++++++++++++++ 8 files changed, 269 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/docs-sync.yml create mode 100644 .pre-commit-config.yaml create mode 100644 CLAUDE.md create mode 100755 scripts/check-docs-sync.sh diff --git a/.github/workflows/docs-sync.yml b/.github/workflows/docs-sync.yml new file mode 100644 index 0000000..42f7d05 --- /dev/null +++ b/.github/workflows/docs-sync.yml @@ -0,0 +1,33 @@ +name: Docs sync + +on: + pull_request: + branches: [master] + push: + branches: [master] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + docs-sync: + name: README ↔ AGENTS.md + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 20 + + - name: Check docs sync + env: + GITHUB_BASE_REF: ${{ github.base_ref }} + run: bash scripts/check-docs-sync.sh diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..39bfa25 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,12 @@ +# Standard pre-commit hooks: https://pre-commit.com/ +# Install once: pip install pre-commit && pre-commit install +# Runs the same check as CI (scripts/check-docs-sync.sh). +repos: + - repo: local + hooks: + - id: docs-sync + name: README.md ↔ AGENTS.md sync + entry: bash scripts/check-docs-sync.sh + language: system + pass_filenames: false + always_run: true diff --git a/AGENTS.md b/AGENTS.md index e195e38..232f899 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,24 @@ # AGENTS.md -Guidance for AI coding agents and human contributors working in this repository. +Canonical guide for AI coding agents working in this repository ([AGENTS.md](https://agents.md/) open standard — complements the human-facing [README.md](./README.md)). + +## Keep in sync + +| File | Audience | Role | +|------|----------|------| +| [README.md](./README.md) | Humans / npm consumers | Install, quick start, API reference | +| **AGENTS.md** (this file) | Agents (+ contributor ops) | Hard rules, architecture, test/debug commands | +| [CLAUDE.md](./CLAUDE.md) | Claude Code | Thin pointer to this file — do not fork rules there | + +**Shared facts** (version matrix, peer floors, iOS 15.5, Zip Slip / `ERR_UNSAFE_PATH`, public API names) must agree between `README.md` and this file. Prefer one canonical sentence + a cross-link over two diverging copies. + +When you change doc-impacting code (`index.js`, `index.d.ts`, `specs/`, `android/src/`, `ios/RNZipArchive.*`, `package.json`, `app.plugin.js`): + +1. Update **both** `README.md` and `AGENTS.md` in the same change when user-facing or agent-facing guidance shifts. +2. Run `npm run test:docs-sync` (same check as CI and optional [pre-commit](https://pre-commit.com/)). +3. Rare internal-only change with no doc impact: commit message may include `[docs-sync skip]`. + +Enforcement is **portable** (not editor-specific): `scripts/check-docs-sync.sh` → GitHub Actions `docs-sync.yml` + optional `.pre-commit-config.yaml`. ## What this is @@ -63,6 +81,7 @@ Run from repo root: ```bash npm test # Jest — preferred fast check for JS/API changes npm run test:interop # Node unzipper + Java ZipInputStream on fixtures/ +npm run test:docs-sync # README ↔ AGENTS.md (same as CI / pre-commit) npm run lint # ESLint on index.js ``` @@ -98,19 +117,21 @@ Treat playground edits as optional unless the task is demo/E2E related. Prefer k | File | Audience | |------|----------| -| `README.md` | Install, quick start, API reference | +| `README.md` | Humans — install, quick start, API | +| `AGENTS.md` | Agents — this file (canonical) | +| `CLAUDE.md` | Claude Code — pointer to `AGENTS.md` | | `MIGRATION.md` | Version upgrades (v7→v9, minor notes) | | `SECURITY.md` | Supported versions, vuln reporting, Zip Slip scope | | `REVIEW.md` | PR review focus (parity, security, threading) | -| `AGENTS.md` | This file — agent/contributor working map | | `CHANGELOG.md` | Release history | -Keep README examples accurate to `index.d.ts`. Prefer linking here or to `MIGRATION.md` for deep architecture detail instead of duplicating long matrices in the README. +Keep README examples accurate to `index.d.ts`. Prefer linking here or to `MIGRATION.md` for deep architecture detail instead of duplicating long matrices in the README. See [Keep in sync](#keep-in-sync). ## CI workflows (`.github/workflows/`) | Workflow | What it covers | |----------|----------------| +| `docs-sync.yml` | README ↔ AGENTS.md fact + change pairing | | `android-build.yml` / `ios-build.yml` | Native builds | | `e2e.yml` | Maestro E2E | | `old-arch.yml` | RN 0.81.6 paper/old-arch compile | @@ -127,8 +148,9 @@ Keep README examples accurate to `index.d.ts`. Prefer linking here or to `MIGRAT 3. `index.js` wrappers (options / AbortSignal / validation). 4. `index.d.ts` overloads. 5. Jest mocks + tests. -6. README API section (short example). -7. `MIGRATION.md` if behavior/default changes for existing callers. +6. README API section (short example) **and** this file if hard rules/commands change. +7. `npm run test:docs-sync` +8. `MIGRATION.md` if behavior/default changes for existing callers. ### Encryption / interop change @@ -157,4 +179,4 @@ Keep README examples accurate to `index.d.ts`. Prefer linking here or to `MIGRAT - [ ] Skim `specs/NativeZipArchive.ts` + `index.d.ts` for the real API. - [ ] For native bugs: find the path in `ios/RNZipArchive.mm` and `android/src/main/java/com/rnziparchive/`. - [ ] Run `npm test` after JS changes; `npm run test:interop` after format/crypto changes. -- [ ] Update docs only for the surfaces you changed (avoid drive-by README rewrites unless asked). +- [ ] Update docs for the surfaces you changed; keep README + AGENTS.md paired (`npm run test:docs-sync`). diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1f6f2df --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,5 @@ +# Claude Code + +Canonical agent instructions live in [AGENTS.md](./AGENTS.md) ([agents.md](https://agents.md/) open standard). + +Do not fork project rules here — update `AGENTS.md` (and `README.md` for human-facing facts) instead. diff --git a/README.md b/README.md index 2a81d1f..7800c7a 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,8 @@ New Architecture is recommended. See [MIGRATION.md](./MIGRATION.md) for upgrades | iOS | ≥ 15.5 | | Android | API 23+ | +Published version and peer ranges: see [`package.json`](./package.json). + ## Installation ### React Native @@ -264,11 +266,11 @@ Stable `error.code` on both platforms (also on `ErrorCodes`): ## Old architecture (RN 0.70–0.81) -Do not stay on v7 for old architecture on RN 0.70+. Install latest v9 and rebuild native. +Stay on v7 only for RN **< 0.70**. On 0.70+, install latest v9 and rebuild native — do not stay on v7 for old architecture. -v9 loads via `TurboModuleRegistry` first, then `NativeModules.RNZipArchive`. On RN 0.82+, the old-architecture opt-out flags are ignored. +v9 loads via `TurboModuleRegistry` first, then `NativeModules.RNZipArchive`. On RN **0.82+**, the opt-out flags `newArchEnabled=false` / `RCT_NEW_ARCH_ENABLED=0` are ignored (New Architecture only). -CI compile proof for RN 0.81.6: [`.github/workflows/old-arch.yml`](./.github/workflows/old-arch.yml). Details for agents and contributors: [AGENTS.md](./AGENTS.md). +CI compile proof for RN 0.81.6: [`.github/workflows/old-arch.yml`](./.github/workflows/old-arch.yml). Agent/contributor details: [AGENTS.md](./AGENTS.md). ## Playgrounds @@ -296,6 +298,7 @@ Use this library for on-device native zip/unzip. Use JSZip for small in-JS archi ```bash npm test # Jest (JS layer + mocks) npm run test:interop # Node/Java unzip of committed fixtures +npm run test:docs-sync # README ↔ AGENTS.md fact + change pairing ``` E2E (Maestro): see [e2e/README.md](./e2e/README.md). @@ -311,8 +314,9 @@ Supported versions and reporting: [SECURITY.md](./SECURITY.md). ## Contributing - Use the [playground apps](#playgrounds) to exercise changes. -- Agent / contributor map: [AGENTS.md](./AGENTS.md). +- **[AGENTS.md](./AGENTS.md)** — canonical agent guide ([agents.md](https://agents.md/) standard). README is for humans; keep shared facts in sync (`npm run test:docs-sync`). - Review focus areas: [REVIEW.md](./REVIEW.md). +- Optional local gate: [pre-commit](https://pre-commit.com/) (`.pre-commit-config.yaml`). ### Minor releases diff --git a/__tests__/package-metadata.test.js b/__tests__/package-metadata.test.js index a9d658e..d25b13c 100644 --- a/__tests__/package-metadata.test.js +++ b/__tests__/package-metadata.test.js @@ -88,6 +88,7 @@ describe('docs claims vs native source (RNZA-7/15/17/19)', () => { test('README documents old-arch v9 load path and playground-rn CI', () => { const readme = read('README.md'); + const agents = read('AGENTS.md'); const migration = read('MIGRATION.md'); const pkgJava = read('android/src/main/java/com/rnziparchive/RNZipArchivePackage.java'); expect(readme).toMatch(/old-arch\.yml/); @@ -96,6 +97,9 @@ describe('docs claims vs native source (RNZA-7/15/17/19)', () => { expect(readme).toMatch(/Stay on v7 only for RN/); expect(readme).toMatch(/0\.70–0\.81/); expect(readme).toMatch(/0\.82\+/); + expect(readme).toMatch(/\[AGENTS\.md\]/); + expect(agents).toMatch(/Keep in sync/i); + expect(agents).toMatch(/test:docs-sync/); expect(migration).toMatch(/0\.70–0\.81/); expect(migration).toMatch(/recommended, not required on 0\.70–0\.81/); expect(pkgJava).toMatch(/boolean isTurboModule = BuildConfig\.IS_NEW_ARCHITECTURE_ENABLED/); diff --git a/package.json b/package.json index fdbf970..65db292 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "scripts": { "test": "jest", "test:interop": "node scripts/verify-zip-interop.js --expect-fail fixtures/interop/winzip-aes-marker.zip && node scripts/verify-zip-interop.js --fixtures", + "test:docs-sync": "bash scripts/check-docs-sync.sh", "lint": "eslint index.js", "test:e2e:expo:ios": "./scripts/e2e-ios.sh", "test:e2e:expo:android": "./scripts/e2e-android.sh", diff --git a/scripts/check-docs-sync.sh b/scripts/check-docs-sync.sh new file mode 100755 index 0000000..7d26235 --- /dev/null +++ b/scripts/check-docs-sync.sh @@ -0,0 +1,177 @@ +#!/usr/bin/env bash +# Portable docs sync check (CI + pre-commit + local). +# +# Convention: https://agents.md/ +# README.md — humans (install, API, examples) +# AGENTS.md — agents (canonical agent guide; AAIF / Linux Foundation) +# +# Checks: +# 1) Fact sync — shared product facts appear in both docs +# 2) Change pairing — doc-impacting code changes must update both docs +# +# Usage: +# scripts/check-docs-sync.sh +# scripts/check-docs-sync.sh --base origin/master +# DOCS_SYNC_SKIP=1 scripts/check-docs-sync.sh +# +# Escape hatch in git history: include [docs-sync skip] in a commit message. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +BASE_REF="" +while [[ $# -gt 0 ]]; do + case "$1" in + --base) + BASE_REF="${2:-}" + shift 2 + ;; + -h|--help) + sed -n '2,22p' "$0" + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + exit 1 + ;; + esac +done + +if [[ "${DOCS_SYNC_SKIP:-}" == "1" ]]; then + echo "docs-sync: skipped (DOCS_SYNC_SKIP=1)" + exit 0 +fi + +FAILED=0 +fail() { + echo "docs-sync: ERROR: $*" >&2 + FAILED=1 +} + +[[ -f README.md ]] || fail "README.md missing" +[[ -f AGENTS.md ]] || fail "AGENTS.md missing" + +PKG_VERSION="$(node -p "require('./package.json').version")" +RN_PEER="$(node -p "require('./package.json').peerDependencies['react-native']")" +REACT_PEER="$(node -p "require('./package.json').peerDependencies.react")" +RN_MIN="$(printf '%s' "$RN_PEER" | sed -E 's/^[^0-9]*([0-9]+\.[0-9]+).*/\1/')" +REACT_MIN="$(printf '%s' "$REACT_PEER" | sed -E 's/^[^0-9]*([0-9]+).*/\1/')" + +require_in_both() { + local label="$1" + local pattern="$2" + if ! grep -Eq "$pattern" README.md; then + fail "README.md missing shared fact (${label}): /${pattern}/" + fi + if ! grep -Eq "$pattern" AGENTS.md; then + fail "AGENTS.md missing shared fact (${label}): /${pattern}/" + fi +} + +# package.json is canonical for version numbers; docs must not invent peers. +require_in_both "point at package.json for current version" "package\\.json" +require_in_both "React Native minimum (${RN_MIN})" "${RN_MIN}" +require_in_both "React minimum (${REACT_MIN})" "${REACT_MIN}" +require_in_both "iOS 15.5 deployment target" "15\\.5" +require_in_both "Zip Slip / ERR_UNSAFE_PATH" "ERR_UNSAFE_PATH|Zip Slip" + +if ! grep -Ei 'keep in sync' AGENTS.md >/dev/null; then + fail "AGENTS.md needs a 'Keep in sync' section (see https://agents.md/)" +fi + +if ! grep -Eq '\[AGENTS\.md\]' README.md; then + fail "README.md should link to AGENTS.md" +fi + +if ! grep -Eq 'README\.md' AGENTS.md; then + fail "AGENTS.md should mention README.md (human docs)" +fi + +# Optional: CLAUDE.md (and similar) should defer to AGENTS.md, not fork rules. +if [[ -f CLAUDE.md ]]; then + if ! grep -Eq 'AGENTS\.md' CLAUDE.md; then + fail "CLAUDE.md exists but does not reference AGENTS.md (keep one canonical agent guide)" + fi +fi + +resolve_base() { + if [[ -n "$BASE_REF" ]]; then + printf '%s' "$BASE_REF" + return + fi + if [[ -n "${GITHUB_BASE_REF:-}" ]]; then + printf 'origin/%s' "$GITHUB_BASE_REF" + return + fi + if git rev-parse --verify --quiet origin/master >/dev/null; then + printf 'origin/master' + return + fi + if git rev-parse --verify --quiet master >/dev/null; then + printf 'master' + return + fi + printf '' +} + +is_doc_impacting() { + case "$1" in + index.js|index.d.ts|app.plugin.js|package.json|RNZipArchive.podspec) return 0 ;; + specs/*|android/src/*|ios/RNZipArchive.mm|ios/RNZipArchive.h) return 0 ;; + *) return 1 ;; + esac +} + +BASE="$(resolve_base)" + +if [[ -n "$BASE" ]] && git rev-parse --verify --quiet "$BASE" >/dev/null; then + MERGE_BASE="$(git merge-base "$BASE" HEAD 2>/dev/null || true)" + if [[ -n "$MERGE_BASE" ]]; then + RANGE="${MERGE_BASE}..HEAD" + else + RANGE="${BASE}...HEAD" + fi + + CHANGED="$(git diff --name-only "$RANGE" 2>/dev/null || true)" + + impacting=0 + readme_changed=0 + agents_changed=0 + while IFS= read -r f; do + [[ -z "$f" ]] && continue + if is_doc_impacting "$f"; then + impacting=1 + fi + [[ "$f" == "README.md" ]] && readme_changed=1 + [[ "$f" == "AGENTS.md" ]] && agents_changed=1 + done <<< "$CHANGED" + + if git log --format=%B "$RANGE" 2>/dev/null | grep -Eq '\[docs-sync skip\]'; then + echo "docs-sync: change pairing skipped ([docs-sync skip] in commit range)" + impacting=0 + fi + + if [[ "$impacting" -eq 1 ]]; then + if [[ "$readme_changed" -ne 1 || "$agents_changed" -ne 1 ]]; then + fail "doc-impacting code changed vs ${BASE}, but both README.md and AGENTS.md were not updated (${RANGE})" + echo "docs-sync: update both (README = humans, AGENTS.md = agents), or commit with [docs-sync skip]." >&2 + echo "docs-sync: files in range:" >&2 + printf '%s\n' "$CHANGED" | sed 's/^/ /' >&2 + else + echo "docs-sync: change pairing ok (code + README.md + AGENTS.md vs ${BASE})" + fi + else + echo "docs-sync: no doc-impacting code changes vs ${BASE} (or pairing skipped)" + fi +else + echo "docs-sync: skipping change pairing (no git base ref)" +fi + +if [[ "$FAILED" -ne 0 ]]; then + echo "docs-sync: failed — keep README.md (humans) and AGENTS.md (agents) aligned." >&2 + exit 1 +fi + +echo "docs-sync: ok (package ${PKG_VERSION}, peers RN ${RN_PEER}, React ${REACT_PEER})" +exit 0 From ad8ba798790dc3ddc19ad35be71e116ee24462f4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 26 Sep 2026 05:46:40 +0000 Subject: [PATCH 3/3] ci: skip native/E2E builds on docs-only changes Gate Android, iOS, E2E, and old-arch jobs behind dorny/paths-filter using .github/path-filters.yml. A cheap changes job still runs so concurrency can cancel in-flight heavy runs; expensive jobs skip when only docs/scripts change. Add a lightweight JS Tests workflow for Jest. Co-authored-by: Perry --- .github/path-filters.yml | 37 ++++++++++++++++++++ .github/workflows/android-build.yml | 18 ++++++++++ .github/workflows/e2e.yml | 21 +++++++++++- .github/workflows/ios-build.yml | 18 ++++++++++ .github/workflows/js-tests.yml | 53 +++++++++++++++++++++++++++++ .github/workflows/old-arch.yml | 20 +++++++++++ .github/workflows/path-filters.md | 15 ++++++++ .github/workflows/zip-interop.yml | 18 ++++++++++ AGENTS.md | 15 ++++---- 9 files changed, 208 insertions(+), 7 deletions(-) create mode 100644 .github/path-filters.yml create mode 100644 .github/workflows/js-tests.yml create mode 100644 .github/workflows/path-filters.md diff --git a/.github/path-filters.yml b/.github/path-filters.yml new file mode 100644 index 0000000..119a356 --- /dev/null +++ b/.github/path-filters.yml @@ -0,0 +1,37 @@ +# Shared path filters for dorny/paths-filter (see heavy CI workflows). +# Docs-only / AGENTS.md / README changes must NOT set native or e2e. + +native: + - android/** + - ios/** + - index.js + - index.d.ts + - specs/** + - app.plugin.js + - RNZipArchive.podspec + - babel.config.js + - playground-rn/** + - playground-expo/** + - __mocks__/** + - .maestro/** + - scripts/e2e-*.sh + - e2e/** + +interop: + - scripts/verify-zip-interop.js + - scripts/validate-zip-header.js + - scripts/ZipInputStreamCheck.java + - scripts/generate-interop-fixtures.py + - fixtures/interop/** + - package.json + +js: + - index.js + - index.d.ts + - __tests__/** + - __mocks__/** + - package.json + - babel.config.js + - app.plugin.js + - scripts/** + - jest.config.* diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml index f36e19a..3b5c7de 100644 --- a/.github/workflows/android-build.yml +++ b/.github/workflows/android-build.yml @@ -12,8 +12,26 @@ concurrency: cancel-in-progress: true jobs: + changes: + name: Detect native changes + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + native: ${{ steps.filter.outputs.native }} + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - name: Path filter + uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + id: filter + with: + filters: .github/path-filters.yml + build-android: name: Build Android (${{ matrix.app }}) + needs: changes + if: ${{ needs.changes.outputs.native == 'true' || github.event_name == 'workflow_dispatch' }} runs-on: ubuntu-latest timeout-minutes: 45 strategy: diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 5700b61..c4b2e98 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -1,5 +1,4 @@ name: E2E Tests -# Test trigger for CI verification on: pull_request: @@ -13,8 +12,26 @@ concurrency: cancel-in-progress: true jobs: + changes: + name: Detect native changes + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + native: ${{ steps.filter.outputs.native }} + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - name: Path filter + uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + id: filter + with: + filters: .github/path-filters.yml + e2e-ios: name: E2E iOS (${{ matrix.app }}) + needs: changes + if: ${{ needs.changes.outputs.native == 'true' || github.event_name == 'workflow_dispatch' }} runs-on: ${{ matrix.runner }} timeout-minutes: 60 strategy: @@ -145,6 +162,8 @@ jobs: e2e-android: name: E2E Android (${{ matrix.app }}) + needs: changes + if: ${{ needs.changes.outputs.native == 'true' || github.event_name == 'workflow_dispatch' }} runs-on: ubuntu-latest timeout-minutes: 60 strategy: diff --git a/.github/workflows/ios-build.yml b/.github/workflows/ios-build.yml index 1ee74e6..9762e25 100644 --- a/.github/workflows/ios-build.yml +++ b/.github/workflows/ios-build.yml @@ -12,8 +12,26 @@ concurrency: cancel-in-progress: true jobs: + changes: + name: Detect native changes + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + native: ${{ steps.filter.outputs.native }} + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - name: Path filter + uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + id: filter + with: + filters: .github/path-filters.yml + build-ios: name: Build iOS (${{ matrix.app }}) + needs: changes + if: ${{ needs.changes.outputs.native == 'true' || github.event_name == 'workflow_dispatch' }} runs-on: ${{ matrix.runner }} timeout-minutes: 45 strategy: diff --git a/.github/workflows/js-tests.yml b/.github/workflows/js-tests.yml new file mode 100644 index 0000000..817a010 --- /dev/null +++ b/.github/workflows/js-tests.yml @@ -0,0 +1,53 @@ +name: JS Tests + +on: + pull_request: + branches: [master] + push: + branches: [master] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + changes: + name: Detect JS changes + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + js: ${{ steps.filter.outputs.js }} + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - name: Path filter + uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + id: filter + with: + filters: .github/path-filters.yml + + jest: + name: Jest + needs: changes + if: ${{ needs.changes.outputs.js == 'true' || github.event_name == 'workflow_dispatch' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 20 + + - name: Install dependencies + run: npm install + + - name: Run Jest + run: npm test + + - name: Lint + run: npm run lint diff --git a/.github/workflows/old-arch.yml b/.github/workflows/old-arch.yml index d81b14d..6d6c3c7 100644 --- a/.github/workflows/old-arch.yml +++ b/.github/workflows/old-arch.yml @@ -19,8 +19,26 @@ env: RN_OLD_ARCH_CLI: '15.1.3' jobs: + changes: + name: Detect native changes + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + native: ${{ steps.filter.outputs.native }} + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - name: Path filter + uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + id: filter + with: + filters: .github/path-filters.yml + compile-android-0-81: name: Compile Android old-arch (RN 0.81) + needs: changes + if: ${{ needs.changes.outputs.native == 'true' || github.event_name == 'workflow_dispatch' }} runs-on: ubuntu-latest timeout-minutes: 45 steps: @@ -96,6 +114,8 @@ jobs: compile-ios-0-81: name: Compile iOS old-arch (RN 0.81) + needs: changes + if: ${{ needs.changes.outputs.native == 'true' || github.event_name == 'workflow_dispatch' }} runs-on: macos-15 timeout-minutes: 45 steps: diff --git a/.github/workflows/path-filters.md b/.github/workflows/path-filters.md new file mode 100644 index 0000000..52f1cf8 --- /dev/null +++ b/.github/workflows/path-filters.md @@ -0,0 +1,15 @@ +# CI path filters + +Heavy workflows use [`dorny/paths-filter`](https://github.com/dorny/paths-filter) with [`.github/path-filters.yml`](../path-filters.yml): + +1. Every push still **starts** the workflow (so `concurrency: cancel-in-progress` can stop an outdated run). +2. A cheap `changes` job decides whether native/E2E work is needed. +3. Expensive jobs run only when the matching filter is `true`, or on `workflow_dispatch`. + +| Filter | Typical workflows | +|--------|-------------------| +| `native` | Android Build, iOS Build, E2E, Old Architecture | +| `interop` | Zip Interop | +| `js` | JS Tests (Jest) | + +Documentation (`*.md`, `AGENTS.md`, `CLAUDE.md`, docs-sync script, pre-commit) does **not** match `native` / `e2e`. `package.json` alone does not trigger native/E2E (it does trigger JS Tests + Zip Interop). Peer bumps that need a native rebuild should include a native/JS file change, or use **Run workflow**. diff --git a/.github/workflows/zip-interop.yml b/.github/workflows/zip-interop.yml index eb9f42f..817ea3a 100644 --- a/.github/workflows/zip-interop.yml +++ b/.github/workflows/zip-interop.yml @@ -15,8 +15,26 @@ concurrency: cancel-in-progress: true jobs: + changes: + name: Detect interop changes + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + interop: ${{ steps.filter.outputs.interop }} + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - name: Path filter + uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + id: filter + with: + filters: .github/path-filters.yml + zip-interop: name: Node unzipper + Java ZipInputStream + needs: changes + if: ${{ needs.changes.outputs.interop == 'true' || github.event_name == 'workflow_dispatch' }} runs-on: ubuntu-latest timeout-minutes: 10 steps: diff --git a/AGENTS.md b/AGENTS.md index 232f899..29cdf7b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -131,14 +131,17 @@ Keep README examples accurate to `index.d.ts`. Prefer linking here or to `MIGRAT | Workflow | What it covers | |----------|----------------| -| `docs-sync.yml` | README ↔ AGENTS.md fact + change pairing | -| `android-build.yml` / `ios-build.yml` | Native builds | -| `e2e.yml` | Maestro E2E | -| `old-arch.yml` | RN 0.81.6 paper/old-arch compile | -| `zip-interop.yml` | Fixture unzip interop | -| `publish.yml` | npm publish | +| `docs-sync.yml` | README ↔ AGENTS.md fact + change pairing (always) | +| `js-tests.yml` | Jest + lint when `js` paths change | +| `android-build.yml` / `ios-build.yml` | Native builds — only when `native` paths change | +| `e2e.yml` | Maestro E2E — only when `native` paths change | +| `old-arch.yml` | RN 0.81.6 paper/old-arch compile — `native` paths | +| `zip-interop.yml` | Fixture unzip interop — `interop` paths | +| `publish.yml` | npm publish (tags) | | `minor-discussion.yml` | Announcement Discussion for minors | +Path filters live in [`.github/path-filters.yml`](./.github/path-filters.yml) (see [`.github/workflows/path-filters.md`](./.github/workflows/path-filters.md)). Docs-only PRs must not burn macOS/Android build minutes: heavy jobs are gated behind a cheap `changes` job so `concurrency: cancel-in-progress` can still stop outdated runs. Force a full build with **Actions → Run workflow**. + ## Common tasks ### Add or change a public API