diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c2be46b..7701f97 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,8 +17,12 @@ jobs: - uses: actions/setup-node@v5 with: node-version-file: .nvmrc + - name: Test native release checks + run: node --test scripts/check-native-releases.test.mjs - run: corepack enable - run: yarn install --immutable - run: yarn typecheck - run: yarn test - run: yarn prepare + - name: Test Expo plugin resolution + run: node --test scripts/expo-plugin.test.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 80b4388..41f3795 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,11 +23,15 @@ jobs: echo "::error::Tag $GITHUB_REF_NAME does not match package.json version $version" exit 1 fi + - name: Require published native SDKs + run: node scripts/check-native-releases.mjs - run: corepack enable - run: yarn install --immutable - run: yarn typecheck - run: yarn test - run: yarn prepare + - name: Test Expo plugin resolution + run: node --test scripts/expo-plugin.test.mjs # Trusted publishing is used when configured on npmjs.com; NPM_TOKEN is # only needed for the first publish, before the package exists. - run: npm publish --provenance diff --git a/package.json b/package.json index 45619e2..b984a6a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@exostack/carillon-react-native", - "version": "0.4.1", + "version": "0.4.2", "description": "React Native bridge over the Carillon native SDKs.", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -10,7 +10,9 @@ "types": "./lib/typescript/src/index.d.ts", "default": "./lib/module/index.js" }, - "./package.json": "./package.json" + "./package.json": "./package.json", + "./app.plugin.js": "./app.plugin.js", + "./app.plugin": "./app.plugin.js" }, "files": [ "src", diff --git a/scripts/check-native-releases.mjs b/scripts/check-native-releases.mjs new file mode 100644 index 0000000..b91ed29 --- /dev/null +++ b/scripts/check-native-releases.mjs @@ -0,0 +1,84 @@ +import { execFile } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import { promisify } from "node:util"; +import { pathToFileURL } from "node:url"; + +export function nativeVersions(gradle, podspec) { + const kotlin = gradle.match(/\bsdkVersion:\s*"(\d+\.\d+\.\d+)"/)?.[1]; + const swift = podspec.match(/\bminimumVersion:\s*"(\d+\.\d+\.\d+)"/)?.[1]; + if (!kotlin || !swift) + throw new Error( + "Cannot read native SDK versions from the Gradle build and podspec.", + ); + return { kotlin, swift }; +} + +export async function checkNativeReleases( + { kotlin, swift }, + { + request = fetch, + listTags = (url, ref) => + promisify(execFile)( + "git", + ["ls-remote", "--exit-code", "--tags", url, ref], + { timeout: 30_000 }, + ), + } = {}, +) { + const ref = `refs/tags/${swift}`; + let tags; + try { + tags = await listTags( + "https://github.com/exostack/carillon-swift.git", + ref, + ); + } catch (cause) { + throw new Error( + `Swift SDK ${swift} is not available. Publish its tag before releasing React Native.`, + { cause }, + ); + } + if (!tags.stdout.split("\n").some((line) => line.split("\t")[1] === ref)) { + throw new Error( + `Swift SDK ${swift} tag is missing. Publish it before releasing React Native.`, + ); + } + // A successful publishing workflow is not enough: consumers need both artifacts on Central. + for (const extension of ["pom", "aar"]) { + const url = `https://repo.maven.apache.org/maven2/com/exostack/carillon/${kotlin}/carillon-${kotlin}.${extension}`; + const response = await request(url, { + method: "HEAD", + signal: AbortSignal.timeout(30_000), + }); + if (!response.ok) { + throw new Error( + `Kotlin SDK ${kotlin} ${extension} is not available on Maven Central (HTTP ${response.status}). Publish it and wait for availability before releasing React Native.`, + ); + } + } +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + try { + const versions = nativeVersions( + await readFile( + new URL("../android/build.gradle", import.meta.url), + "utf8", + ), + await readFile( + new URL("../CarillonReactNative.podspec", import.meta.url), + "utf8", + ), + ); + await checkNativeReleases(versions); + console.log( + `Native dependencies are published: Swift ${versions.swift}, Kotlin ${versions.kotlin}.`, + ); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/scripts/check-native-releases.test.mjs b/scripts/check-native-releases.test.mjs new file mode 100644 index 0000000..6b5730f --- /dev/null +++ b/scripts/check-native-releases.test.mjs @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + checkNativeReleases, + nativeVersions, +} from "./check-native-releases.mjs"; + +const versions = { kotlin: "0.4.0", swift: "0.4.0" }; +const listTags = async () => ({ stdout: "abcdef\trefs/tags/0.4.0\n" }); + +test("reads each version from its consumer declaration", () => { + assert.deepEqual( + nativeVersions('sdkVersion: "0.4.0"', 'minimumVersion: "0.5.0"'), + { kotlin: "0.4.0", swift: "0.5.0" }, + ); + assert.throws(() => nativeVersions("", ""), /Cannot read/); +}); + +test("requires both Maven artifacts and the Swift tag", async () => { + const urls = []; + await checkNativeReleases(versions, { + listTags, + request: async (url) => { + urls.push(url); + return { ok: true }; + }, + }); + assert.deepEqual( + urls.map((url) => url.split(".").at(-1)), + ["pom", "aar"], + ); +}); + +for (const missing of ["pom", "aar"]) { + test(`blocks publication when the Maven ${missing} is missing`, async () => { + await assert.rejects( + checkNativeReleases(versions, { + listTags, + request: async (url) => ({ ok: !url.endsWith(missing), status: 404 }), + }), + /not available on Maven Central/, + ); + }); +} + +test("blocks publication when the Swift tag is missing", async () => { + await assert.rejects( + checkNativeReleases(versions, { listTags: async () => ({ stdout: "" }) }), + /tag is missing/, + ); +}); + +test("fails closed on network errors", async () => { + await assert.rejects( + checkNativeReleases(versions, { + listTags: async () => { + throw new Error("network"); + }, + }), + /Swift SDK/, + ); + await assert.rejects( + checkNativeReleases(versions, { + listTags, + request: async () => { + throw new Error("network"); + }, + }), + /network/, + ); +}); diff --git a/scripts/expo-plugin.test.mjs b/scripts/expo-plugin.test.mjs new file mode 100644 index 0000000..35949ce --- /dev/null +++ b/scripts/expo-plugin.test.mjs @@ -0,0 +1,31 @@ +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import { test } from "node:test"; + +const require = createRequire(import.meta.url); +const { + resolvePluginForModule, +} = require("@expo/config-plugins/build/utils/plugin-resolver.js"); +const root = fileURLToPath(new URL("../", import.meta.url)); +const entry = fileURLToPath(new URL("../app.plugin.js", import.meta.url)); + +test("exports the Expo plugin entry point", () => { + assert.equal( + require.resolve("@exostack/carillon-react-native/app.plugin.js"), + entry, + ); + assert.equal( + typeof require("@exostack/carillon-react-native/app.plugin.js"), + "function", + ); +}); + +test("Expo resolves the plugin by package name", () => { + const resolved = resolvePluginForModule( + root, + "@exostack/carillon-react-native", + ); + assert.equal(resolved.filePath, entry); + assert.equal(resolved.isPluginFile, true); +});