@forthtilliath/expo-release-updates 0.7.1 → 0.9.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 +28 -12
- package/dist/downloadAndInstallApk.d.ts +23 -4
- package/dist/downloadAndInstallApk.d.ts.map +1 -1
- package/dist/downloadAndInstallApk.js +46 -4
- package/dist/githubReleases.d.ts +38 -3
- package/dist/githubReleases.d.ts.map +1 -1
- package/dist/githubReleases.js +89 -17
- package/package.json +7 -7
package/README.md
CHANGED
|
@@ -76,9 +76,13 @@ if (release && isUpdateAvailable(currentVersion, release.version)) {
|
|
|
76
76
|
}
|
|
77
77
|
```
|
|
78
78
|
|
|
79
|
-
### `fetchLatestRelease({ owner, repo, token?, timeoutMs?, signal? })`
|
|
79
|
+
### `fetchLatestRelease({ owner, repo, includePrereleases?, requireVerifiedApk?, token?, timeoutMs?, signal? })`
|
|
80
80
|
|
|
81
|
-
Fetches the latest GitHub release and its `.apk` asset
|
|
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`.
|
|
84
|
+
|
|
85
|
+
Pass `requireVerifiedApk: true` to only consider an `.apk` that `downloadAndInstallApk` with `expectedSha256` and `allowedUrlPrefix: releaseDownloadUrlPrefix(...)` would accept: one with a SHA-256, under this repo's release URLs. A release whose APK fails either is skipped (with `includePrereleases`, the next highest version wins), so the app never offers an update the install would then refuse. Every field of GitHub's answer is type-checked and bounded: notes are cut at 100,000 characters, an oversized tag or URL counts as missing, and a release without a tag is ignored.
|
|
82
86
|
|
|
83
87
|
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
88
|
|
|
@@ -104,22 +108,34 @@ const history = await fetchReleaseHistory({
|
|
|
104
108
|
// [{ version: "1.11.0", notes: "### Added\n- ...", publishedAt: "2026-07-30T..." }, ...]
|
|
105
109
|
```
|
|
106
110
|
|
|
107
|
-
### `downloadAndInstallApk({ apkUrl, fileName, onProgress?, expectedMd5?, allowedHosts? })`
|
|
111
|
+
### `downloadAndInstallApk({ apkUrl, fileName, onProgress?, expectedSha256?, allowedUrlPrefix?, expectedMd5?, allowedHosts? })`
|
|
112
|
+
|
|
113
|
+
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.
|
|
114
|
+
|
|
115
|
+
Recommended checks, both fed by `fetchLatestRelease`:
|
|
108
116
|
|
|
109
|
-
|
|
117
|
+
- **`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).
|
|
118
|
+
- **`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
119
|
|
|
111
|
-
|
|
120
|
+
`expectedMd5` remains for a checksum you publish yourself (release notes, `.md5` asset).
|
|
112
121
|
|
|
113
|
-
|
|
122
|
+
What a checksum does and doesn't protect against:
|
|
123
|
+
|
|
124
|
+
- ✅ a corrupted, truncated or swapped download;
|
|
114
125
|
- ❌ 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
126
|
|
|
116
127
|
```ts
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
128
|
+
const repo = { owner: "acme", repo: "app" };
|
|
129
|
+
const release = await fetchLatestRelease(repo);
|
|
130
|
+
if (release) {
|
|
131
|
+
await downloadAndInstallApk({
|
|
132
|
+
apkUrl: release.apkUrl,
|
|
133
|
+
fileName: "myapp-update.apk",
|
|
134
|
+
onProgress: (fraction) => setProgress(fraction),
|
|
135
|
+
allowedUrlPrefix: releaseDownloadUrlPrefix(repo),
|
|
136
|
+
expectedSha256: release.apkSha256,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
123
139
|
```
|
|
124
140
|
|
|
125
141
|
### `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
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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":"
|
|
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
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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,
|
package/dist/githubReleases.d.ts
CHANGED
|
@@ -20,6 +20,32 @@ 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;
|
|
40
|
+
/**
|
|
41
|
+
* Only consider an `.apk` the recommended install would accept: one with a
|
|
42
|
+
* SHA-256 (`apkSha256`, for `downloadAndInstallApk`'s `expectedSha256`)
|
|
43
|
+
* and a URL under `releaseDownloadUrlPrefix(options)` (its
|
|
44
|
+
* `allowedUrlPrefix`). A release whose APK fails either is skipped — with
|
|
45
|
+
* `includePrereleases`, the next highest version wins — so nothing is
|
|
46
|
+
* offered that the install would then refuse. Defaults to `false`.
|
|
47
|
+
*/
|
|
48
|
+
requireVerifiedApk?: boolean;
|
|
23
49
|
}
|
|
24
50
|
export interface ReleaseHistoryEntry {
|
|
25
51
|
version: string;
|
|
@@ -27,12 +53,21 @@ export interface ReleaseHistoryEntry {
|
|
|
27
53
|
publishedAt: string;
|
|
28
54
|
}
|
|
29
55
|
/**
|
|
30
|
-
*
|
|
56
|
+
* `https://github.com/<owner>/<repo>/releases/download/` — where every asset
|
|
57
|
+
* of this repo's releases lives. Pass it to `downloadAndInstallApk`'s
|
|
58
|
+
* `allowedUrlPrefix` so only an APK of this very repo can be installed.
|
|
59
|
+
*/
|
|
60
|
+
export declare function releaseDownloadUrlPrefix({ owner, repo }: GithubRepoRef): string;
|
|
61
|
+
/**
|
|
62
|
+
* Fetches the latest GitHub release for a repo, along with its `.apk` asset
|
|
63
|
+
* and the SHA-256 GitHub computed for it. Stable releases only, unless
|
|
64
|
+
* `includePrereleases` is set.
|
|
31
65
|
*
|
|
32
|
-
* @returns `null` if the latest release has no `.apk` asset attached
|
|
66
|
+
* @returns `null` if the latest release has no `.apk` asset attached (with
|
|
67
|
+
* `includePrereleases`: if none of the 10 most recent releases has one).
|
|
33
68
|
* @throws if the GitHub API request fails or times out.
|
|
34
69
|
*/
|
|
35
|
-
export declare function fetchLatestRelease(
|
|
70
|
+
export declare function fetchLatestRelease(options: FetchLatestReleaseOptions): Promise<LatestRelease | null>;
|
|
36
71
|
/**
|
|
37
72
|
* Fetches the most recent published releases for a repo (version, notes,
|
|
38
73
|
* 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":"
|
|
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;IAC7B;;;;;;;OAOG;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;AAgCD;;;;GAIG;AACH,wBAAgB,wBAAwB,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,EAAE,aAAa,UAEtE;AAwGD;;;;;;;;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"}
|
package/dist/githubReleases.js
CHANGED
|
@@ -1,9 +1,71 @@
|
|
|
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;
|
|
8
|
+
/** Release notes longer than this are cut: rendered as is, they'd stall the UI. */
|
|
9
|
+
const MAX_NOTES_LENGTH = 100_000;
|
|
10
|
+
/** Tags, asset names, URLs, dates: anything longer is treated as missing. */
|
|
11
|
+
const MAX_FIELD_LENGTH = 2_048;
|
|
4
12
|
function releasesUrl({ owner, repo }) {
|
|
5
13
|
return `https://api.github.com/repos/${owner}/${repo}/releases`;
|
|
6
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* `https://github.com/<owner>/<repo>/releases/download/` — where every asset
|
|
17
|
+
* of this repo's releases lives. Pass it to `downloadAndInstallApk`'s
|
|
18
|
+
* `allowedUrlPrefix` so only an APK of this very repo can be installed.
|
|
19
|
+
*/
|
|
20
|
+
export function releaseDownloadUrlPrefix({ owner, repo }) {
|
|
21
|
+
return `https://github.com/${owner}/${repo}/releases/download/`;
|
|
22
|
+
}
|
|
23
|
+
function text(value) {
|
|
24
|
+
return typeof value === "string" && value.length <= MAX_FIELD_LENGTH
|
|
25
|
+
? value
|
|
26
|
+
: "";
|
|
27
|
+
}
|
|
28
|
+
function notesOf(release) {
|
|
29
|
+
return typeof release.body === "string"
|
|
30
|
+
? release.body.slice(0, MAX_NOTES_LENGTH)
|
|
31
|
+
: "";
|
|
32
|
+
}
|
|
33
|
+
function isObject(value) {
|
|
34
|
+
return typeof value === "object" && value !== null;
|
|
35
|
+
}
|
|
36
|
+
function releasesIn(data) {
|
|
37
|
+
return Array.isArray(data) ? data.filter(isObject) : [];
|
|
38
|
+
}
|
|
39
|
+
function versionOf(release) {
|
|
40
|
+
return text(release.tag_name).replace(/^v/, "");
|
|
41
|
+
}
|
|
42
|
+
function apkOf(release, options) {
|
|
43
|
+
const assets = Array.isArray(release.assets)
|
|
44
|
+
? release.assets.filter(isObject)
|
|
45
|
+
: [];
|
|
46
|
+
const prefix = releaseDownloadUrlPrefix(options);
|
|
47
|
+
for (const asset of assets) {
|
|
48
|
+
const apkUrl = text(asset.browser_download_url);
|
|
49
|
+
if (!text(asset.name).endsWith(".apk") || apkUrl === "")
|
|
50
|
+
continue;
|
|
51
|
+
const sha256 = SHA256_DIGEST.exec(text(asset.digest))?.[1]?.toLowerCase();
|
|
52
|
+
if (options.requireVerifiedApk && (!sha256 || !apkUrl.startsWith(prefix))) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
return { apkUrl, ...(sha256 ? { apkSha256: sha256 } : {}) };
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
function toLatestRelease(release, options) {
|
|
60
|
+
const version = versionOf(release);
|
|
61
|
+
// Without a tag, there's nothing to compare the installed version with.
|
|
62
|
+
if (version === "")
|
|
63
|
+
return null;
|
|
64
|
+
const apk = apkOf(release, options);
|
|
65
|
+
if (!apk)
|
|
66
|
+
return null;
|
|
67
|
+
return { version, notes: notesOf(release), ...apk };
|
|
68
|
+
}
|
|
7
69
|
function githubHeaders({ token }) {
|
|
8
70
|
return {
|
|
9
71
|
Accept: "application/vnd.github+json",
|
|
@@ -51,21 +113,31 @@ async function fetchGithubJson(url, ref) {
|
|
|
51
113
|
}
|
|
52
114
|
}
|
|
53
115
|
/**
|
|
54
|
-
* Fetches the latest GitHub release for a repo, along with its `.apk` asset
|
|
116
|
+
* Fetches the latest GitHub release for a repo, along with its `.apk` asset
|
|
117
|
+
* and the SHA-256 GitHub computed for it. Stable releases only, unless
|
|
118
|
+
* `includePrereleases` is set.
|
|
55
119
|
*
|
|
56
|
-
* @returns `null` if the latest release has no `.apk` asset attached
|
|
120
|
+
* @returns `null` if the latest release has no `.apk` asset attached (with
|
|
121
|
+
* `includePrereleases`: if none of the 10 most recent releases has one).
|
|
57
122
|
* @throws if the GitHub API request fails or times out.
|
|
58
123
|
*/
|
|
59
|
-
export async function fetchLatestRelease(
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
124
|
+
export async function fetchLatestRelease(options) {
|
|
125
|
+
if (!options.includePrereleases) {
|
|
126
|
+
const data = await fetchGithubJson(`${releasesUrl(options)}/latest`, options);
|
|
127
|
+
return isObject(data) ? toLatestRelease(data, options) : null;
|
|
128
|
+
}
|
|
129
|
+
const data = await fetchGithubJson(`${releasesUrl(options)}?per_page=${String(PRERELEASE_SCAN)}`, options);
|
|
130
|
+
let latest = null;
|
|
131
|
+
for (const release of releasesIn(data)) {
|
|
132
|
+
if (release.draft === true)
|
|
133
|
+
continue;
|
|
134
|
+
const candidate = toLatestRelease(release, options);
|
|
135
|
+
if (candidate &&
|
|
136
|
+
(!latest || compareVersions(candidate.version, latest.version) > 0)) {
|
|
137
|
+
latest = candidate;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return latest;
|
|
69
141
|
}
|
|
70
142
|
/**
|
|
71
143
|
* Fetches the most recent published releases for a repo (version, notes,
|
|
@@ -76,11 +148,11 @@ export async function fetchReleaseHistory(ref) {
|
|
|
76
148
|
const { limit = 10 } = ref;
|
|
77
149
|
const perPage = Math.min(Math.max(Math.trunc(limit), 1), MAX_PER_PAGE);
|
|
78
150
|
const data = await fetchGithubJson(`${releasesUrl(ref)}?per_page=${String(perPage)}`, ref);
|
|
79
|
-
return data
|
|
80
|
-
.filter((release) =>
|
|
151
|
+
return releasesIn(data)
|
|
152
|
+
.filter((release) => release.draft !== true)
|
|
81
153
|
.map((release) => ({
|
|
82
|
-
version: (release
|
|
83
|
-
notes: release
|
|
84
|
-
publishedAt: release.published_at
|
|
154
|
+
version: versionOf(release),
|
|
155
|
+
notes: notesOf(release),
|
|
156
|
+
publishedAt: text(release.published_at),
|
|
85
157
|
}));
|
|
86
158
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forthtilliath/expo-release-updates",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"license": "MIT",
|
|
@@ -34,18 +34,18 @@
|
|
|
34
34
|
}
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@forthtilliath/ts-kit": "^0.
|
|
37
|
+
"@forthtilliath/ts-kit": "^0.15.0"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
|
-
"@vitest/coverage-v8": "5.0.
|
|
40
|
+
"@vitest/coverage-v8": "5.0.5",
|
|
41
41
|
"eslint": "10.7.0",
|
|
42
42
|
"expo-file-system": "~57.0.2",
|
|
43
43
|
"expo-intent-launcher": "~57.0.1",
|
|
44
44
|
"typescript": "^5.9.3",
|
|
45
|
-
"vitest": "^5.0.
|
|
46
|
-
"@forthtilliath/
|
|
47
|
-
"@forthtilliath/eslint-config": "0.
|
|
48
|
-
"@forthtilliath/
|
|
45
|
+
"vitest": "^5.0.5",
|
|
46
|
+
"@forthtilliath/typescript-config": "0.1.1",
|
|
47
|
+
"@forthtilliath/eslint-config": "0.10.0",
|
|
48
|
+
"@forthtilliath/test-kit": "0.4.0"
|
|
49
49
|
},
|
|
50
50
|
"scripts": {
|
|
51
51
|
"lint": "eslint . --max-warnings 0",
|