@forthtilliath/expo-release-updates 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -76,9 +76,11 @@ if (release && isUpdateAvailable(currentVersion, release.version)) {
76
76
  }
77
77
  ```
78
78
 
79
- ### `fetchLatestRelease({ owner, repo, token?, timeoutMs?, signal? })`
79
+ ### `fetchLatestRelease({ owner, repo, includePrereleases?, token?, timeoutMs?, signal? })`
80
80
 
81
- Fetches the latest GitHub release and its `.apk` asset. Returns `null` if the latest release has no `.apk` attached; throws if the GitHub API request fails. Pass `token` for private repos, or to raise the API rate limit from 60 to 5000 requests/hour on public ones.
81
+ Fetches the latest GitHub release and its `.apk` asset: `{ version, notes, apkUrl, apkSha256? }`. `apkSha256` is the SHA-256 GitHub computed when the APK was uploaded — pass it to `downloadAndInstallApk`'s `expectedSha256`. Returns `null` if the latest release has no `.apk` attached; throws if the GitHub API request fails. Pass `token` for private repos, or to raise the API rate limit from 60 to 5000 requests/hour on public ones.
82
+
83
+ GitHub's `/releases/latest` only ever returns a **stable** release: an app that only publishes pre-releases (alpha, beta) would never see an update. Pass `includePrereleases: true` to scan the 10 most recent releases instead (drafts left out) and keep the highest version (semver, `1.0.0-alpha.10` > `1.0.0-alpha.2`) that carries an `.apk`.
82
84
 
83
85
  The request gives up after `timeoutMs` (default `15000`) instead of hanging on a bad network, and can be cancelled with `signal` (an `AbortSignal`) — both also apply to `fetchReleaseHistory`.
84
86
 
@@ -104,22 +106,34 @@ const history = await fetchReleaseHistory({
104
106
  // [{ version: "1.11.0", notes: "### Added\n- ...", publishedAt: "2026-07-30T..." }, ...]
105
107
  ```
106
108
 
107
- ### `downloadAndInstallApk({ apkUrl, fileName, onProgress?, expectedMd5?, allowedHosts? })`
109
+ ### `downloadAndInstallApk({ apkUrl, fileName, onProgress?, expectedSha256?, allowedUrlPrefix?, expectedMd5?, allowedHosts? })`
110
+
111
+ Downloads an APK to the app's cache directory and triggers the Android install-package intent. **Android only**. `apkUrl` must be an `https://` URL on one of `allowedHosts` (default `["github.com"]`, where `fetchLatestRelease`'s `apkUrl` points) — anything else throws before downloading.
112
+
113
+ Recommended checks, both fed by `fetchLatestRelease`:
108
114
 
109
- Downloads an APK to the app's cache directory and triggers the Android install-package intent. **Android only** — there is no iOS equivalent (sideloading isn't possible there). `apkUrl` must be an `https://` URL on one of `allowedHosts` (default `["github.com"]`, where `fetchLatestRelease`'s `apkUrl` points) — anything else throws before downloading. Pass `expectedMd5` to verify the downloaded file before installing — a mismatch deletes the file and throws instead. Publish the checksum alongside the release (e.g. in the release notes or a `.md5` asset) and read it yourself: `fetchLatestRelease` doesn't return it.
115
+ - **`allowedUrlPrefix: releaseDownloadUrlPrefix({ owner, repo })`** — only an APK of _this_ repo's releases (`https://github.com/<owner>/<repo>/releases/download/…`): `allowedHosts` alone accepts any github.com URL, another repo's included. After the prefix, only plain path segments pass (no `..`, query, fragment or `%`-escape).
116
+ - **`expectedSha256: release.apkSha256`** — the downloaded file must match the SHA-256 GitHub computed on upload; a mismatch deletes it and throws. Computed natively with `File#digest`, so it needs **expo-file-system 58 or later** (with an older one, it throws rather than skip the check).
110
117
 
111
- What `expectedMd5` does and doesn't protect against:
118
+ `expectedMd5` remains for a checksum you publish yourself (release notes, `.md5` asset).
112
119
 
113
- - ✅ a corrupted or truncated download;
120
+ What a checksum does and doesn't protect against:
121
+
122
+ - ✅ a corrupted, truncated or swapped download;
114
123
  - ❌ a malicious release — whoever can publish the APK can publish its checksum too. The real safeguard there is Android itself: it refuses to install an update that isn't signed with the same key as the installed app. **Keep that signing key out of the repo and CI logs**, and restrict who can publish releases.
115
124
 
116
125
  ```ts
117
- await downloadAndInstallApk({
118
- apkUrl: release.apkUrl,
119
- fileName: "myapp-update.apk",
120
- onProgress: (fraction) => setProgress(fraction),
121
- expectedMd5, // read from the release notes or a .md5 asset
122
- });
126
+ const repo = { owner: "acme", repo: "app" };
127
+ const release = await fetchLatestRelease(repo);
128
+ if (release) {
129
+ await downloadAndInstallApk({
130
+ apkUrl: release.apkUrl,
131
+ fileName: "myapp-update.apk",
132
+ onProgress: (fraction) => setProgress(fraction),
133
+ allowedUrlPrefix: releaseDownloadUrlPrefix(repo),
134
+ expectedSha256: release.apkSha256,
135
+ });
136
+ }
123
137
  ```
124
138
 
125
139
  ### `parseChangelogNotes(notes)`
@@ -12,6 +12,16 @@ export interface DownloadAndInstallApkOptions {
12
12
  * signed with the same key as the installed app is what guards against that.
13
13
  */
14
14
  expectedMd5?: string;
15
+ /**
16
+ * SHA-256 the downloaded file must match — `apkSha256` from
17
+ * `fetchLatestRelease`, computed by GitHub itself when the asset was
18
+ * uploaded (`"sha256:…"` or bare hex, any case). A mismatch deletes the
19
+ * file and rejects instead of installing it. Same limits as `expectedMd5`
20
+ * against a malicious release, but SHA-256 can't be forged to match a
21
+ * tampered file. Needs expo-file-system 58 or later (`File#digest`, native,
22
+ * without loading the APK in JS); with an older one, it rejects.
23
+ */
24
+ expectedSha256?: string;
15
25
  /**
16
26
  * Hosts `apkUrl` may point to — `["github.com"]` by default, the host of
17
27
  * the `apkUrl` returned by `fetchLatestRelease` (GitHub then redirects to
@@ -19,15 +29,24 @@ export interface DownloadAndInstallApkOptions {
19
29
  * rejected before downloading anything.
20
30
  */
21
31
  allowedHosts?: string[];
32
+ /**
33
+ * The start `apkUrl` must have, e.g. `releaseDownloadUrlPrefix({ owner,
34
+ * repo })` to only accept an APK of this repo's releases (any github.com
35
+ * URL passes `allowedHosts`, another repo's included). After the prefix,
36
+ * only plain path segments are allowed: no `..`, query, fragment or
37
+ * `%`-escape.
38
+ */
39
+ allowedUrlPrefix?: string;
22
40
  }
23
41
  /**
24
42
  * Downloads an APK to the cache directory and triggers the Android
25
43
  * install-package intent. Android only — there is no equivalent flow on iOS,
26
44
  * which doesn't allow installing a downloaded package outside the App Store.
27
45
  *
28
- * @throws if `apkUrl` isn't an `https://` URL on one of `allowedHosts`, if
29
- * the download fails, or if `expectedMd5` is set and doesn't match the
30
- * downloaded file.
46
+ * @throws if `apkUrl` isn't an `https://` URL on one of `allowedHosts` (or
47
+ * under `allowedUrlPrefix`), if `expectedSha256` isn't a SHA-256, if the
48
+ * download fails, or if `expectedMd5` / `expectedSha256` is set and doesn't
49
+ * match the downloaded file.
31
50
  */
32
- export declare function downloadAndInstallApk({ apkUrl, fileName, onProgress, expectedMd5, allowedHosts, }: DownloadAndInstallApkOptions): Promise<void>;
51
+ export declare function downloadAndInstallApk({ apkUrl, fileName, onProgress, expectedMd5, expectedSha256, allowedHosts, allowedUrlPrefix, }: DownloadAndInstallApkOptions): Promise<void>;
33
52
  //# sourceMappingURL=downloadAndInstallApk.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"downloadAndInstallApk.d.ts","sourceRoot":"","sources":["../src/downloadAndInstallApk.ts"],"names":[],"mappings":"AAqBA,MAAM,WAAW,4BAA4B;IAC3C,MAAM,EAAE,MAAM,CAAC;IACf,qEAAqE;IACrE,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC;;;;;;;OAOG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;OAKG;IACH,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;CACzB;AAED;;;;;;;;GAQG;AACH,wBAAsB,qBAAqB,CAAC,EAC1C,MAAM,EACN,QAAQ,EACR,UAAU,EACV,WAAW,EACX,YAAoC,GACrC,EAAE,4BAA4B,GAAG,OAAO,CAAC,IAAI,CAAC,CAqC9C"}
1
+ {"version":3,"file":"downloadAndInstallApk.d.ts","sourceRoot":"","sources":["../src/downloadAndInstallApk.ts"],"names":[],"mappings":"AAmDA,MAAM,WAAW,4BAA4B;IAC3C,MAAM,EAAE,MAAM,CAAC;IACf,qEAAqE;IACrE,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC;;;;;;;OAOG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;;;;OAQG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;;OAKG;IACH,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB;;;;;;OAMG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED;;;;;;;;;GASG;AACH,wBAAsB,qBAAqB,CAAC,EAC1C,MAAM,EACN,QAAQ,EACR,UAAU,EACV,WAAW,EACX,cAAc,EACd,YAAoC,EACpC,gBAAgB,GACjB,EAAE,4BAA4B,GAAG,OAAO,CAAC,IAAI,CAAC,CAmE9C"}
@@ -15,21 +15,51 @@ function httpsHost(url) {
15
15
  const match = /^https:\/\/([^/?#:@]+)(?::\d+)?(?:[/?#]|$)/i.exec(url);
16
16
  return match?.[1]?.toLowerCase() ?? null;
17
17
  }
18
+ /**
19
+ * Whether `url` is `prefix` followed by plain path segments only: letters,
20
+ * digits, `.`, `_`, `-` — no `..` segment, query, fragment, `%`-escape or
21
+ * `@` that could lead it out of the prefix.
22
+ */
23
+ function isUnderPrefix(url, prefix) {
24
+ if (!url.startsWith(prefix))
25
+ return false;
26
+ const rest = url.slice(prefix.length);
27
+ return (/^[\w.-]+(\/[\w.-]+)*$/.test(rest) &&
28
+ rest.split("/").every((segment) => segment !== "." && segment !== ".."));
29
+ }
30
+ const SHA256_HEX = /^[0-9a-f]{64}$/;
31
+ /** `"sha256:ABC…"` or `"abc…"` → `"abc…"`, or `null` if not a SHA-256. */
32
+ function normalizeSha256(value) {
33
+ const hex = value
34
+ .trim()
35
+ .replace(/^sha256:/i, "")
36
+ .toLowerCase();
37
+ return SHA256_HEX.test(hex) ? hex : null;
38
+ }
18
39
  /**
19
40
  * Downloads an APK to the cache directory and triggers the Android
20
41
  * install-package intent. Android only — there is no equivalent flow on iOS,
21
42
  * which doesn't allow installing a downloaded package outside the App Store.
22
43
  *
23
- * @throws if `apkUrl` isn't an `https://` URL on one of `allowedHosts`, if
24
- * the download fails, or if `expectedMd5` is set and doesn't match the
25
- * downloaded file.
44
+ * @throws if `apkUrl` isn't an `https://` URL on one of `allowedHosts` (or
45
+ * under `allowedUrlPrefix`), if `expectedSha256` isn't a SHA-256, if the
46
+ * download fails, or if `expectedMd5` / `expectedSha256` is set and doesn't
47
+ * match the downloaded file.
26
48
  */
27
- export async function downloadAndInstallApk({ apkUrl, fileName, onProgress, expectedMd5, allowedHosts = DEFAULT_ALLOWED_HOSTS, }) {
49
+ export async function downloadAndInstallApk({ apkUrl, fileName, onProgress, expectedMd5, expectedSha256, allowedHosts = DEFAULT_ALLOWED_HOSTS, allowedUrlPrefix, }) {
28
50
  const host = httpsHost(apkUrl);
29
51
  if (!host ||
30
52
  !allowedHosts.some((allowed) => allowed.toLowerCase() === host)) {
31
53
  throw new Error(`Refusing to download the APK from ${apkUrl}: expected an https:// URL on ${allowedHosts.join(", ")}.`);
32
54
  }
55
+ if (allowedUrlPrefix !== undefined &&
56
+ !isUnderPrefix(apkUrl, allowedUrlPrefix)) {
57
+ throw new Error(`Refusing to download the APK from ${apkUrl}: expected a file under ${allowedUrlPrefix}.`);
58
+ }
59
+ const sha256 = expectedSha256 === undefined ? undefined : normalizeSha256(expectedSha256);
60
+ if (sha256 === null) {
61
+ throw new Error(`Invalid expectedSha256: "${String(expectedSha256)}".`);
62
+ }
33
63
  const destination = new File(Paths.cache, fileName);
34
64
  if (destination.exists)
35
65
  destination.delete();
@@ -49,6 +79,18 @@ export async function downloadAndInstallApk({ apkUrl, fileName, onProgress, expe
49
79
  throw new Error(`APK checksum mismatch: expected ${expectedMd5}, got ${actualMd5 ?? "none"}.`);
50
80
  }
51
81
  }
82
+ if (sha256) {
83
+ const { digest } = file;
84
+ if (typeof digest !== "function") {
85
+ file.delete();
86
+ throw new Error("expectedSha256 needs expo-file-system 58 or later (File#digest).");
87
+ }
88
+ const actualSha256 = (await digest.call(file, "SHA-256")).toLowerCase();
89
+ if (actualSha256 !== sha256) {
90
+ file.delete();
91
+ throw new Error(`APK SHA-256 mismatch: expected ${sha256}, got ${actualSha256}.`);
92
+ }
93
+ }
52
94
  await IntentLauncher.startActivityAsync("android.intent.action.VIEW", {
53
95
  data: file.contentUri,
54
96
  flags: FLAG_GRANT_READ_URI_PERMISSION,
@@ -20,6 +20,23 @@ export interface LatestRelease {
20
20
  version: string;
21
21
  notes: string;
22
22
  apkUrl: string;
23
+ /**
24
+ * SHA-256 of the `.apk` (64 lowercase hex digits), as computed by GitHub
25
+ * when the asset was uploaded — pass it to `downloadAndInstallApk`'s
26
+ * `expectedSha256`. Missing for assets uploaded before GitHub computed
27
+ * digests (mid-2025).
28
+ */
29
+ apkSha256?: string;
30
+ }
31
+ export interface FetchLatestReleaseOptions extends GithubRepoRef {
32
+ /**
33
+ * Also consider pre-releases. GitHub's `/releases/latest` only ever returns
34
+ * a stable release, so an app that only publishes pre-releases (alpha,
35
+ * beta) would never see an update: with this flag, the most recent
36
+ * releases are scanned instead and the highest version carrying an `.apk`
37
+ * wins. Defaults to `false`.
38
+ */
39
+ includePrereleases?: boolean;
23
40
  }
24
41
  export interface ReleaseHistoryEntry {
25
42
  version: string;
@@ -27,12 +44,21 @@ export interface ReleaseHistoryEntry {
27
44
  publishedAt: string;
28
45
  }
29
46
  /**
30
- * Fetches the latest GitHub release for a repo, along with its `.apk` asset.
47
+ * `https://github.com/<owner>/<repo>/releases/download/` — where every asset
48
+ * of this repo's releases lives. Pass it to `downloadAndInstallApk`'s
49
+ * `allowedUrlPrefix` so only an APK of this very repo can be installed.
50
+ */
51
+ export declare function releaseDownloadUrlPrefix({ owner, repo }: GithubRepoRef): string;
52
+ /**
53
+ * Fetches the latest GitHub release for a repo, along with its `.apk` asset
54
+ * and the SHA-256 GitHub computed for it. Stable releases only, unless
55
+ * `includePrereleases` is set.
31
56
  *
32
- * @returns `null` if the latest release has no `.apk` asset attached.
57
+ * @returns `null` if the latest release has no `.apk` asset attached (with
58
+ * `includePrereleases`: if none of the 10 most recent releases has one).
33
59
  * @throws if the GitHub API request fails or times out.
34
60
  */
35
- export declare function fetchLatestRelease(ref: GithubRepoRef): Promise<LatestRelease | null>;
61
+ export declare function fetchLatestRelease(options: FetchLatestReleaseOptions): Promise<LatestRelease | null>;
36
62
  /**
37
63
  * Fetches the most recent published releases for a repo (version, notes,
38
64
  * publish date), most recent first — useful for a "release history" screen.
@@ -1 +1 @@
1
- {"version":3,"file":"githubReleases.d.ts","sourceRoot":"","sources":["../src/githubReleases.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb;;;;;OAKG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,4EAA4E;IAC5E,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;CACrB;AAqED;;;;;GAKG;AACH,wBAAsB,kBAAkB,CACtC,GAAG,EAAE,aAAa,GACjB,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAe/B;AAED;;;;GAIG;AACH,wBAAsB,mBAAmB,CACvC,GAAG,EAAE,aAAa,GAAG;IAAE,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,GACtC,OAAO,CAAC,mBAAmB,EAAE,CAAC,CAchC"}
1
+ {"version":3,"file":"githubReleases.d.ts","sourceRoot":"","sources":["../src/githubReleases.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb;;;;;OAKG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,4EAA4E;IAC5E,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf;;;;;OAKG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,yBAA0B,SAAQ,aAAa;IAC9D;;;;;;OAMG;IACH,kBAAkB,CAAC,EAAE,OAAO,CAAC;CAC9B;AAED,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;CACrB;AA4BD;;;;GAIG;AACH,wBAAgB,wBAAwB,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,EAAE,aAAa,UAEtE;AAyFD;;;;;;;;GAQG;AACH,wBAAsB,kBAAkB,CACtC,OAAO,EAAE,yBAAyB,GACjC,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAyB/B;AAED;;;;GAIG;AACH,wBAAsB,mBAAmB,CACvC,GAAG,EAAE,aAAa,GAAG;IAAE,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,GACtC,OAAO,CAAC,mBAAmB,EAAE,CAAC,CAchC"}
@@ -1,9 +1,53 @@
1
+ import { compareVersions } from "./compareVersions.js";
1
2
  const DEFAULT_TIMEOUT_MS = 15_000;
2
3
  /** GitHub's own max for `per_page`. */
3
4
  const MAX_PER_PAGE = 100;
5
+ /** Enough to skip a few releases without an APK (notes only, drafts). */
6
+ const PRERELEASE_SCAN = 10;
7
+ const SHA256_DIGEST = /^sha256:([0-9a-f]{64})$/i;
4
8
  function releasesUrl({ owner, repo }) {
5
9
  return `https://api.github.com/repos/${owner}/${repo}/releases`;
6
10
  }
11
+ /**
12
+ * `https://github.com/<owner>/<repo>/releases/download/` — where every asset
13
+ * of this repo's releases lives. Pass it to `downloadAndInstallApk`'s
14
+ * `allowedUrlPrefix` so only an APK of this very repo can be installed.
15
+ */
16
+ export function releaseDownloadUrlPrefix({ owner, repo }) {
17
+ return `https://github.com/${owner}/${repo}/releases/download/`;
18
+ }
19
+ function text(value) {
20
+ return typeof value === "string" ? value : "";
21
+ }
22
+ function isObject(value) {
23
+ return typeof value === "object" && value !== null;
24
+ }
25
+ function releasesIn(data) {
26
+ return Array.isArray(data) ? data.filter(isObject) : [];
27
+ }
28
+ function versionOf(release) {
29
+ return text(release.tag_name).replace(/^v/, "");
30
+ }
31
+ function apkOf(release) {
32
+ const assets = Array.isArray(release.assets)
33
+ ? release.assets.filter(isObject)
34
+ : [];
35
+ const apk = assets.find((asset) => text(asset.name).endsWith(".apk") &&
36
+ text(asset.browser_download_url) !== "");
37
+ if (!apk)
38
+ return null;
39
+ const sha256 = SHA256_DIGEST.exec(text(apk.digest))?.[1]?.toLowerCase();
40
+ return {
41
+ apkUrl: text(apk.browser_download_url),
42
+ ...(sha256 ? { apkSha256: sha256 } : {}),
43
+ };
44
+ }
45
+ function toLatestRelease(release) {
46
+ const apk = apkOf(release);
47
+ if (!apk)
48
+ return null;
49
+ return { version: versionOf(release), notes: text(release.body), ...apk };
50
+ }
7
51
  function githubHeaders({ token }) {
8
52
  return {
9
53
  Accept: "application/vnd.github+json",
@@ -51,21 +95,31 @@ async function fetchGithubJson(url, ref) {
51
95
  }
52
96
  }
53
97
  /**
54
- * Fetches the latest GitHub release for a repo, along with its `.apk` asset.
98
+ * Fetches the latest GitHub release for a repo, along with its `.apk` asset
99
+ * and the SHA-256 GitHub computed for it. Stable releases only, unless
100
+ * `includePrereleases` is set.
55
101
  *
56
- * @returns `null` if the latest release has no `.apk` asset attached.
102
+ * @returns `null` if the latest release has no `.apk` asset attached (with
103
+ * `includePrereleases`: if none of the 10 most recent releases has one).
57
104
  * @throws if the GitHub API request fails or times out.
58
105
  */
59
- export async function fetchLatestRelease(ref) {
60
- const data = await fetchGithubJson(`${releasesUrl(ref)}/latest`, ref);
61
- const apkAsset = (data.assets ?? []).find((asset) => asset.name.endsWith(".apk"));
62
- if (!apkAsset)
63
- return null;
64
- return {
65
- version: (data.tag_name ?? "").replace(/^v/, ""),
66
- notes: data.body ?? "",
67
- apkUrl: apkAsset.browser_download_url,
68
- };
106
+ export async function fetchLatestRelease(options) {
107
+ if (!options.includePrereleases) {
108
+ const data = await fetchGithubJson(`${releasesUrl(options)}/latest`, options);
109
+ return isObject(data) ? toLatestRelease(data) : null;
110
+ }
111
+ const data = await fetchGithubJson(`${releasesUrl(options)}?per_page=${String(PRERELEASE_SCAN)}`, options);
112
+ let latest = null;
113
+ for (const release of releasesIn(data)) {
114
+ if (release.draft === true)
115
+ continue;
116
+ const candidate = toLatestRelease(release);
117
+ if (candidate &&
118
+ (!latest || compareVersions(candidate.version, latest.version) > 0)) {
119
+ latest = candidate;
120
+ }
121
+ }
122
+ return latest;
69
123
  }
70
124
  /**
71
125
  * Fetches the most recent published releases for a repo (version, notes,
@@ -76,11 +130,11 @@ export async function fetchReleaseHistory(ref) {
76
130
  const { limit = 10 } = ref;
77
131
  const perPage = Math.min(Math.max(Math.trunc(limit), 1), MAX_PER_PAGE);
78
132
  const data = await fetchGithubJson(`${releasesUrl(ref)}?per_page=${String(perPage)}`, ref);
79
- return data
80
- .filter((release) => !release.draft)
133
+ return releasesIn(data)
134
+ .filter((release) => release.draft !== true)
81
135
  .map((release) => ({
82
- version: (release.tag_name ?? "").replace(/^v/, ""),
83
- notes: release.body ?? "",
84
- publishedAt: release.published_at ?? "",
136
+ version: versionOf(release),
137
+ notes: text(release.body),
138
+ publishedAt: text(release.published_at),
85
139
  }));
86
140
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forthtilliath/expo-release-updates",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "license": "MIT",
@@ -34,7 +34,7 @@
34
34
  }
35
35
  },
36
36
  "dependencies": {
37
- "@forthtilliath/ts-kit": "^0.12.0"
37
+ "@forthtilliath/ts-kit": "^0.14.0"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@vitest/coverage-v8": "5.0.2",
@@ -43,9 +43,9 @@
43
43
  "expo-intent-launcher": "~57.0.1",
44
44
  "typescript": "^5.9.3",
45
45
  "vitest": "^5.0.2",
46
- "@forthtilliath/test-kit": "0.3.0",
47
46
  "@forthtilliath/eslint-config": "0.9.0",
48
- "@forthtilliath/typescript-config": "0.1.1"
47
+ "@forthtilliath/typescript-config": "0.1.1",
48
+ "@forthtilliath/test-kit": "0.4.0"
49
49
  },
50
50
  "scripts": {
51
51
  "lint": "eslint . --max-warnings 0",