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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
name: CI

on:
push:
branches: [main]
pull_request:

concurrency:
group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
js:
name: Typecheck, lint, test, build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

- uses: actions/setup-node@v6
with:
node-version: "24"
cache: npm

- run: npm ci

- run: npm run typecheck

- run: npm run lint

- run: npm test

- run: npm run build

- name: Pack dry-run
run: npm pack --dry-run

android:
name: Compile Android native module
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

- uses: actions/setup-java@v6
with:
distribution: temurin
java-version: "17"

- uses: android-actions/setup-android@v4

- name: Compile library with Gradle
working-directory: ci/android
run: ./gradlew --no-daemon :library:compileDebugJavaWithJavac
55 changes: 55 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
name: Release

on:
push:
tags:
- "v*"

permissions:
contents: read
id-token: write

jobs:
publish:
name: Publish to npm
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

- uses: actions/setup-node@v6
with:
node-version: "24"
registry-url: "https://registry.npmjs.org"
package-manager-cache: false

- run: npm ci

- run: npm run typecheck

- run: npm run lint

- run: npm test

- run: npm run build

- name: Pack dry-run
run: npm pack --dry-run

- name: Check tag matches package.json version
run: |
set -euo pipefail
TAG="${GITHUB_REF_NAME}"
EXPECTED="${TAG#v}"
ACTUAL="$(node -p "require('./package.json').version")"
if [ "$EXPECTED" != "$ACTUAL" ]; then
echo "Refusing to publish: tag '${TAG}' does not match package.json version '${ACTUAL}'."
echo "Create a tag named v\${version} only after bumping package.json (and the changelog) on the same commit."
exit 1
fi
echo "Tag ${TAG} matches package.json version ${ACTUAL}."

- name: Publish to npm
run: npm publish --access public --provenance
env:
# Preferred: npm trusted publishing (OIDC). Optional fallback: NPM_TOKEN secret.
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,8 @@ node_modules
dist
*.tgz
.DS_Store
android/build
ci/android/.gradle
ci/android/local.properties
ci/android/**/build
.gradle
8 changes: 8 additions & 0 deletions .npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Keep pack contents limited when "files" includes directories.
**/__tests__/
**/*.test.ts
**/build/
ci
.github
eslint.config.cjs
jest.config.cjs
41 changes: 41 additions & 0 deletions PUBLISHING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Publishing `@tapapplink/react-native`

Releases publish from GitHub Actions when you push a tag named `vX.Y.Z` that matches `package.json` `"version"`. The workflow refuses to publish if the tag and version disagree (this would have blocked the mismatched `0.2.0` tag that still carried `0.1.3` in `package.json`).

## One-time setup (Kenny)

Prefer **npm trusted publishing (OIDC)**. Use an `NPM_TOKEN` secret only as a fallback.

### Option A — Trusted publishing (recommended)

1. Sign in to [npmjs.com](https://www.npmjs.com/) as a maintainer of `@tapapplink/react-native`.
2. Open the package → **Settings** → **Trusted Publisher**.
3. Add **GitHub Actions** with:
- **Organization or user:** `tapapplink`
- **Repository:** `tapapplink-react-native`
- **Workflow filename:** `release.yml` (filename only, not a path)
- **Environment name:** leave blank unless you later add a GitHub Environment to the release job
- **Allowed actions:** allow `npm publish` (and staged publish if you want it)
4. Complete the first successful tag publish within **2 days** of creating the trusted publisher, or npm expires the unused config.
5. After a successful OIDC publish, optionally tighten the package to require 2FA and disallow classic tokens.

No repo secret is required for Option A. The release workflow already sets `permissions.id-token: write`.

### Option B — `NPM_TOKEN` fallback

1. On npmjs.com, create a granular access token (or automation token) with permission to publish `@tapapplink/react-native`.
2. In the GitHub repo → **Settings** → **Secrets and variables** → **Actions**, add repository secret `NPM_TOKEN` with that value.
3. You can keep this secret even when using trusted publishing; OIDC is tried first and the token is unused when trust is configured.

## Cut a release

1. Bump `"version"` in `package.json` and add a changelog entry on the same commit.
2. Merge to `main`.
3. Tag that commit and push:

```bash
git tag v0.2.1
git push origin v0.2.1
```

Do not retag an old commit with a new version name. The publish job checks that `v*` equals `package.json` version on the tagged commit.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ const offer = TapAppLink.getOffer();

Purchases are attributed with billing **webhooks**, not a client `trackPurchase` call.

## Publishing

Maintainers: see [PUBLISHING.md](./PUBLISHING.md) for the CI release tag flow and one-time npm trusted publishing setup.

## License

MIT
78 changes: 78 additions & 0 deletions __tests__/tapAppLink.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
jest.mock("react-native", () => ({
NativeModules: {},
Platform: { OS: "ios", isPad: false },
}));

import { TapAppLink } from "../src/tapAppLink";

describe("TapAppLink", () => {
beforeEach(async () => {
await TapAppLink.resetForTesting();
jest.restoreAllMocks();
});

it("requires configure before trackInstall", async () => {
await expect(TapAppLink.trackInstall()).rejects.toThrow(
"TapAppLink.configure() must be called first",
);
});

it("tracks install once and caches offer", async () => {
const fetchMock = jest.fn().mockResolvedValue({
json: async () => ({
attributionId: "attr_123",
offer: {
creatorName: "Ada",
promoCode: "ADA10",
billingOfferId: "offer_1",
},
}),
});
global.fetch = fetchMock as unknown as typeof fetch;

TapAppLink.configure({
publicKey: "etk_test",
environment: "sandbox",
ingestUrl: "https://example.test",
});

const first = await TapAppLink.trackInstall();
const second = await TapAppLink.trackInstall();

expect(first).toMatchObject({ attributionId: "attr_123" });
expect(second).toEqual({ matched: false, skipped: true });
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(TapAppLink.getOffer()).toEqual({
creatorName: "Ada",
promoCode: "ADA10",
billingOfferId: "offer_1",
});
expect(TapAppLink.getAttributionId()).toBe("attr_123");
});

it("posts identify with app user id", async () => {
const fetchMock = jest.fn().mockResolvedValue({
json: async () => ({ ok: true }),
});
global.fetch = fetchMock as unknown as typeof fetch;

TapAppLink.configure({
publicKey: "etk_test",
environment: "sandbox",
ingestUrl: "https://example.test",
});

await TapAppLink.setAppUserId("user_42");

expect(TapAppLink.getAppUserId()).toBe("user_42");
expect(fetchMock).toHaveBeenCalledWith(
"https://example.test/ingestIdentify",
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({
Authorization: "Bearer etk_test",
}),
}),
);
});
});
22 changes: 21 additions & 1 deletion android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@ def safeExtGet(prop, fallback) {
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
}

// Standalone CI (ci/android) has no host app classpath; RN host apps do.
buildscript {
if (rootProject.name == "tapapplink-android-ci") {
repositories {
google()
mavenCentral()
}
dependencies {
classpath("com.android.tools.build:gradle:8.7.2")
}
}
}

apply plugin: "com.android.library"

android {
Expand All @@ -20,6 +33,13 @@ android {
}

dependencies {
implementation "com.facebook.react:react-android"
// Host React Native apps leave this unversioned so the app's RN Gradle
// plugin supplies the artifact. Standalone CI sets ext.reactNativeVersion.
def reactNativeVersion = safeExtGet("reactNativeVersion", "")
if (reactNativeVersion) {
implementation "com.facebook.react:react-android:${reactNativeVersion}"
} else {
implementation "com.facebook.react:react-android"
}
implementation "com.android.installreferrer:installreferrer:2.2"
}
6 changes: 6 additions & 0 deletions ci/android/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
ext {
compileSdkVersion = 35
minSdkVersion = 24
targetSdkVersion = 35
reactNativeVersion = "0.76.9"
}
3 changes: 3 additions & 0 deletions ci/android/gradle.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
android.useAndroidX=true
android.nonTransitiveRClass=true
Binary file added ci/android/gradle/wrapper/gradle-wrapper.jar
Binary file not shown.
7 changes: 7 additions & 0 deletions ci/android/gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Loading
Loading