Skip to content

Commit 8120724

Browse files
committed
feat: error reference, device matrix, soak tests and a release pipeline
Four things, all of which exist to make the project's claims checkable rather than asserted. **The error reference** documents every one of the thirty `NfcErrorCode` values with a cause and a remedy, and a test compares it against `NFC_ERROR_CODES` in both directions. A code with no row is the worst omission an error reference can have -- the reader concludes the error is undocumented and guesses -- and a row for a code that no longer exists sends them looking for something they will never see. Rows with empty cells fail too, since a code that has been listed rather than explained is not documented. **The soak test** is the automatable half of "it works the first five times". Thirty iterations of every entry point, then an assertion that the library is back where it started: no active session, no native listener, every handle released. Including the interleaved case, because a leak that only appears when one entry point follows another is exactly what a per-entry-point test misses. Writing it turned up nothing, which is the result worth having. **The device matrix** is 45 rows covering every claim the library makes, each with the setup that tests it and what passed means -- and every row is marked not run, on the first line, because it has not been. Row 21 is called out as the one place the library makes an inference it could not check: the Android 17 `DISPATCH_NFC_MESSAGE` behaviour. **The release pipeline** uses npm trusted publishing, so there is no token in the repository and none should ever be added. The requirements were read from npm's documentation rather than recalled, which corrected two things: Node 22.14 is the floor, not just npm 11.5.1, and the workflow filename is part of the trust configuration -- renaming it breaks publishing with an error that looks like authentication. Every gate runs again inside the release job, because a release is the one place where "it passed earlier" is not good enough. The version becomes 0.9.0. `RELEASING.md` says what a 1.0.0 needs first, and it is not code: the matrix, row 21 settled on hardware, and weeks of real use. A 1.0.0 that has not survived somebody else's tags is a version number rather than a promise. Maestro flows are deliberately not here. They would test the example app's UI, not the library -- whose error paths are already at 100% -- and would need a runtime mock switch inside the example, which means a test seam in shipping code. The reason is recorded rather than the work half-done. 1342 tests.
1 parent df1720a commit 8120724

13 files changed

Lines changed: 1243 additions & 15 deletions

File tree

.changeset/README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Changesets
2+
3+
A changeset is a note about a change, written when the change is made rather than
4+
reconstructed at release time. Run `npx changeset` and answer two questions: how big
5+
the change is, and what to say about it.
6+
7+
Which bump to pick, for this library specifically:
8+
9+
- **patch** — a fix that changes no API and needs no rebuild.
10+
- **minor** — a new capability, a new `NfcErrorCode`, or anything that bumps
11+
`CONTRACT_VERSION`. A contract bump means consumers must rebuild their development
12+
build, so the changeset should say so in as many words.
13+
- **major** — changing the `code` of an existing error, removing an export, or
14+
changing what an existing call does. Renaming a technology counts.
15+
16+
The changelog is generated from these, which is the point: the library this replaces
17+
has a `CHANGELOG.md` frozen at 3.0.2 from February 2021, with four years of releases
18+
missing from it.

.changeset/config.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",
3+
"changelog": "@changesets/cli/changelog",
4+
"commit": false,
5+
"fixed": [],
6+
"linked": [],
7+
"access": "public",
8+
"baseBranch": "main",
9+
"updateInternalDependencies": "patch",
10+
"ignore": []
11+
}

.github/workflows/release.yml

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# Publishing to npm.
2+
#
3+
# There is no NPM_TOKEN here, deliberately. This uses npm's trusted publishing:
4+
# GitHub Actions mints a short-lived OIDC token, npm accepts it because the package
5+
# names this workflow as a trusted publisher, and provenance attestations are
6+
# generated automatically. Classic npm tokens were deprecated on 2025-12-09, and
7+
# automation tokens lose direct publish in January 2027, so a token-based release
8+
# would be a pipeline with a known end date.
9+
#
10+
# **The workflow filename is part of the trust configuration.** npm matches on the
11+
# owner, the repository and this file's name, so renaming this file breaks publishing
12+
# until the trusted publisher is updated on npmjs.com. See RELEASING.md.
13+
name: Release
14+
15+
on:
16+
push:
17+
branches: [main]
18+
19+
concurrency:
20+
group: release
21+
cancel-in-progress: false
22+
23+
jobs:
24+
release:
25+
name: Version or publish
26+
runs-on: ubuntu-latest
27+
28+
permissions:
29+
# Mints the OIDC token npm exchanges for publish rights. Without it the
30+
# publish falls back to looking for a token and fails.
31+
id-token: write
32+
# The changesets action opens and updates the version pull request, and tags
33+
# the release commit.
34+
contents: write
35+
pull-requests: write
36+
37+
steps:
38+
- uses: actions/checkout@v4
39+
with:
40+
# Changesets compares against the base branch to decide what to bump.
41+
fetch-depth: 0
42+
43+
- uses: actions/setup-node@v4
44+
with:
45+
# Trusted publishing needs Node 22.14 or later and npm 11.5.1 or later.
46+
# Pinned rather than left to the runner's default so a runner image
47+
# change cannot silently drop below the floor.
48+
node-version: 22.14.0
49+
registry-url: https://registry.npmjs.org
50+
cache: npm
51+
52+
- name: Use an npm that can publish this way
53+
run: npm install --global npm@latest
54+
55+
- run: npm ci --ignore-scripts
56+
57+
# The gates run again here rather than being trusted from the push that
58+
# triggered this. A release is the one place where "it passed earlier" is not
59+
# good enough: the tree being published is this one.
60+
- name: Verify
61+
run: |
62+
npm run lint
63+
npm run format:check
64+
npm run typecheck
65+
npm run test:coverage
66+
npm run verify:package
67+
68+
- name: Version or publish
69+
uses: changesets/action@v1
70+
with:
71+
# With changesets pending, this opens or updates a pull request that
72+
# applies them. With none, and a version not yet on npm, it publishes.
73+
version: npm run release:version
74+
publish: npm publish
75+
commit: 'chore: release'
76+
title: 'chore: release'
77+
env:
78+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

CHANGELOG.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Changelog
2+
3+
Generated from changesets from `0.9.1` onwards. This first entry is written by hand,
4+
because everything below it predates the changeset workflow.
5+
6+
## 0.9.0
7+
8+
The first version worth installing. Every layer is present and tested; what is
9+
missing is hardware validation, which is why this is not a `1.0.0` — see
10+
[docs/device-matrix.md](docs/device-matrix.md), where not one row has been run yet.
11+
12+
### Reading and writing
13+
14+
- `nfc.withTag`, which closes the session on every path, including a throw, an abort,
15+
a timeout, and the platform ending the session underneath.
16+
- `nfc.openSession` for reading several tags with your own UI in between, as an
17+
`AsyncDisposable` so `await using` works — with a fallback for engines that lack
18+
`Symbol.asyncDispose`, which includes Hermes.
19+
- `nfc.onTag` for continuous reading, tied to subscriber count so reader mode is only
20+
active while something is listening.
21+
- Tags as a discriminated union: `tag.is('ndef')` is what makes `readNdef` exist, in
22+
the type system and at runtime. On iOS `tag.is('mifareClassic')` is always `false`.
23+
- Every rejection is an `NfcError` with a `code`, a `platform`, the original
24+
`nativeCode`, and whether retrying is worth it. See
25+
[docs/errors.md](docs/errors.md).
26+
27+
### Layers that need no device
28+
29+
- `react-native-nfc-kit/ndef` — the NDEF codec, at 100% branch coverage: chunked
30+
records, UTF-16 text, the 36-entry URI prefix table both ways, Smart Posters, Type 2
31+
TLV, and the Type 4 capability container.
32+
- `react-native-nfc-kit/protocols` — ISO 7816 with command chaining and `61xx`/`6Cxx`
33+
handling, ISO 15693, FeliCa, NTAG/Ultralight, all over one native primitive.
34+
35+
### Platform surfaces
36+
37+
- Android reports tag removal (`tag.onLost`), by polling below API 37 and from the
38+
platform above it, deduplicated so it can only fire once.
39+
- Background tags: `nfc.withLaunchTag` and `nfc.onBackgroundTag`, with the launch
40+
intent consumed so a screen rotation cannot replay a tap.
41+
- `react-native-nfc-kit/hce` — card emulation on Android, including an emulated Type 4
42+
tag written in TypeScript, observe mode, and polling loop frames.
43+
- `react-native-nfc-kit/vas` — reading an Apple Wallet pass. Needs an entitlement
44+
Apple grants case by case.
45+
- `react-native-nfc-kit/react` — hooks, on a subpath so the core never imports React.
46+
- Web NFC: the same API in a browser, implemented over the same native contract.
47+
48+
### Tooling
49+
50+
- A config plugin that writes exactly what each option requires and removes it again
51+
when the option goes away, with `dispatchNfcMessagePermission` refusing to guess.
52+
- Setup documentation generated from the plugin itself, so the Expo and bare routes
53+
cannot drift.
54+
- `CONTRACT_VERSION`, so a bundle newer than the installed binary says "rebuild the
55+
development build" rather than failing on an undefined method.

README.md

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,18 @@ npx pod-install
195195
There is no `prebuild` in a bare project, so entitlements and the Android manifest are
196196
edited by hand. See [docs/setup/bare-react-native.md](docs/setup/bare-react-native.md).
197197

198+
## What has not been validated on hardware
199+
200+
Nothing, yet — and that is stated here rather than left to be discovered.
201+
[docs/device-matrix.md](docs/device-matrix.md) is the checklist: 45 rows covering
202+
every claim this library makes, each with the setup that tests it and what "passed"
203+
means. Not one has been run.
204+
205+
The automated suite proves the bookkeeping — including a soak test that runs thirty
206+
iterations of every entry point and asserts nothing is left held. Only a tag proves
207+
the radio, which is why the version is `0.9.0` and why
208+
[RELEASING.md](RELEASING.md) lists what a `1.0.0` needs first.
209+
198210
## Documentation
199211

200212
The full documentation lives under [docs/](docs/) and is built as a Mintlify site
@@ -244,6 +256,7 @@ Nothing here is claimed to work because it looks right.
244256
| Wallet passes | Validation and decoding tested against the fake native module; the read itself needs an entitlement Apple grants case by case |
245257
| Card emulation | The emulated Type 4 tag is driven through a whole reader conversation, using the same functions an app uses to talk to a real card |
246258
| Config plugin | 104 tests through Expo's own introspection compiler, so the assertions are about what `expo prebuild` produces |
259+
| Leaks | 30 iterations of every entry point, asserting no session, no native listener and no handle is left behind |
247260
| The documentation | Every import in a code block is checked against the barrels that define it, and the site's navigation against the files on disk |
248261
| The published package | `publint` and `arethetypeswrong` against a packed tarball, so a broken `exports` map fails before a user finds it |
249262

@@ -253,19 +266,19 @@ release requirement, not an afterthought.
253266

254267
## Roadmap
255268

256-
| Phase | Contents | Status |
257-
| ----- | --------------------------------------------------------------------- | ------ |
258-
| M0 | Repository scaffold, tooling, CI | done |
259-
| M1 | NDEF codec (pure TypeScript, no native) | done |
260-
| M2 | Android core: reader mode, tech adapters, errors, sessions, tags | done |
261-
| M3 | iOS core: session actor, one-shot continuations, tag handles | done |
262-
| M4 | Protocol layers: ISO 7816, ISO 15693, FeliCa, NTAG/Ultralight | done |
263-
| M5 | Config plugin, bare React Native, React hooks | done |
264-
| M6 | Continuous reading, `onTagLost`, observe mode, background tag reading | next |
265-
| M7 | Host card emulation (Android) | done |
266-
| M8 | Observe mode, polling loop frames | done |
267-
| M9 | Web NFC shim, documentation site, migration guide | done |
268-
| M10 | Error reference, device matrix pass, soak tests, `1.0.0` | next |
269+
| Phase | Contents | Status |
270+
| ----- | --------------------------------------------------------------------- | ----------- |
271+
| M0 | Repository scaffold, tooling, CI | done |
272+
| M1 | NDEF codec (pure TypeScript, no native) | done |
273+
| M2 | Android core: reader mode, tech adapters, errors, sessions, tags | done |
274+
| M3 | iOS core: session actor, one-shot continuations, tag handles | done |
275+
| M4 | Protocol layers: ISO 7816, ISO 15693, FeliCa, NTAG/Ultralight | done |
276+
| M5 | Config plugin, bare React Native, React hooks | done |
277+
| M6 | Continuous reading, `onTagLost`, observe mode, background tag reading | next |
278+
| M7 | Host card emulation (Android) | done |
279+
| M8 | Observe mode, polling loop frames | done |
280+
| M9 | Web NFC shim, documentation site, migration guide | done |
281+
| M10 | Error reference, device matrix, soak tests, release pipeline | in progress |
269282

270283
Out of scope, deliberately: Apple's NFC & SE Platform (`CredentialSession`). It requires
271284
an agreement with Apple, ABR onboarding, and an accredited-lab applet security review —

RELEASING.md

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# Releasing
2+
3+
There are no npm tokens in this repository, and there should never be. Publishing
4+
goes through npm's trusted publishing: GitHub Actions mints a short-lived OIDC
5+
token, npm accepts it because the package names the release workflow as a trusted
6+
publisher, and provenance attestations are generated without asking.
7+
8+
Classic npm tokens were deprecated on 2025-12-09 and automation tokens lose direct
9+
publish in January 2027, so a token-based pipeline would be one with a known expiry
10+
date.
11+
12+
## One-time setup on npmjs.com
13+
14+
Before the first publish, on the package's settings page under **Trusted
15+
publishers**, add a GitHub Actions publisher with:
16+
17+
| Field | Value |
18+
| -------------------- | ---------------------- |
19+
| Organization or user | `visibait` |
20+
| Repository | `react-native-nfc-kit` |
21+
| Workflow filename | `release.yml` |
22+
23+
**The workflow filename is part of the trust configuration.** Renaming
24+
`.github/workflows/release.yml` breaks publishing until this is updated, and the
25+
failure looks like an authentication error rather than a configuration one.
26+
27+
The package must exist on npm first. For the very first publish, either create it
28+
with a manual `npm publish` from a machine that is logged in, or reserve the name
29+
and then let the workflow take over.
30+
31+
## The everyday flow
32+
33+
1. **Write a changeset with the change**, not afterwards:
34+
35+
```bash
36+
npx changeset
37+
```
38+
39+
Two questions: how big the change is, and what to say about it. `.changeset/README.md`
40+
explains which bump to pick — the one to get right is that anything bumping
41+
`CONTRACT_VERSION` is a **minor** and the note must say that consumers need to
42+
rebuild their development build, because that is the support question that
43+
otherwise arrives instead.
44+
45+
2. **Merge to `main`.** The release workflow opens or updates a pull request titled
46+
`chore: release` that applies every pending changeset: versions bumped,
47+
`CHANGELOG.md` written, changesets consumed.
48+
49+
3. **Merge that pull request.** The workflow runs again, finds no pending changesets
50+
and a version not yet on npm, and publishes.
51+
52+
Every gate runs again inside the release job rather than being trusted from the push
53+
that triggered it. A release is the one place where "it passed earlier" is not good
54+
enough: the tree being published is this one.
55+
56+
## Before a 1.0.0
57+
58+
The version is `0.9.0`, and that is deliberate. What is missing is not code:
59+
60+
- **[The device matrix](docs/device-matrix.md) has not been run.** Not one row. A
61+
`1.0.0` that has not been through it would be a claim this project has not earned,
62+
and the matrix says as much on its own first line.
63+
- **Row 21 is an unverified inference.** The Android 17 `DISPATCH_NFC_MESSAGE`
64+
behaviour follows from Google's documentation and has not been checked on an API 37
65+
device. The default is the conservative one and the docs call it provisional, but
66+
it should be settled before a stable release.
67+
- **Weeks of real use.** A 1.0.0 that has not survived somebody else's tags is a
68+
version number, not a promise. The plan for this library said so from the start,
69+
and it is worth repeating here where the release happens.
70+
71+
Publishing `0.x` releases in the meantime is the point of the version scheme: each
72+
one is usable, and none of them claims more than has been checked.
73+
74+
## Semantic versioning, as applied here
75+
76+
- Adding an `NfcErrorCode` is a **minor**. Nothing that existed changed meaning.
77+
- Changing the `code` an existing failure produces is a **major**, because callers
78+
branch on codes and a changed code silently takes a branch away.
79+
- Bumping `CONTRACT_VERSION` is a **minor** that requires a rebuild. The runtime says
80+
so itself — a bundle newer than the installed binary fails with `contractMismatch`
81+
and a message naming the remedy — but the changelog should not make anyone
82+
discover it that way.
83+
- Renaming a technology is a **major**. `tag.is('...')` and the config plugin's tech
84+
lists both take those names.

0 commit comments

Comments
 (0)