@forthtilliath/expo-release-updates 0.8.0 → 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 CHANGED
@@ -76,12 +76,14 @@ if (release && isUpdateAvailable(currentVersion, release.version)) {
76
76
  }
77
77
  ```
78
78
 
79
- ### `fetchLatestRelease({ owner, repo, includePrereleases?, token?, timeoutMs?, signal? })`
79
+ ### `fetchLatestRelease({ owner, repo, includePrereleases?, requireVerifiedApk?, token?, timeoutMs?, signal? })`
80
80
 
81
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
82
 
83
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
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.
86
+
85
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`.
86
88
 
87
89
  > **Security — the token ships inside your app.** Anything bundled in an APK can be extracted from it, so treat the token as public: use a [fine-grained token](https://github.com/settings/personal-access-tokens) with **read-only "Contents" access to this one repository** and nothing else, never a classic token or one with write scopes. For a public repo, prefer no token at all.
@@ -37,6 +37,15 @@ export interface FetchLatestReleaseOptions extends GithubRepoRef {
37
37
  * wins. Defaults to `false`.
38
38
  */
39
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;
40
49
  }
41
50
  export interface ReleaseHistoryEntry {
42
51
  version: string;
@@ -1 +1 @@
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
+ {"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"}
@@ -5,6 +5,10 @@ const MAX_PER_PAGE = 100;
5
5
  /** Enough to skip a few releases without an APK (notes only, drafts). */
6
6
  const PRERELEASE_SCAN = 10;
7
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;
8
12
  function releasesUrl({ owner, repo }) {
9
13
  return `https://api.github.com/repos/${owner}/${repo}/releases`;
10
14
  }
@@ -17,7 +21,14 @@ export function releaseDownloadUrlPrefix({ owner, repo }) {
17
21
  return `https://github.com/${owner}/${repo}/releases/download/`;
18
22
  }
19
23
  function text(value) {
20
- return typeof value === "string" ? 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
+ : "";
21
32
  }
22
33
  function isObject(value) {
23
34
  return typeof value === "object" && value !== null;
@@ -28,25 +39,32 @@ function releasesIn(data) {
28
39
  function versionOf(release) {
29
40
  return text(release.tag_name).replace(/^v/, "");
30
41
  }
31
- function apkOf(release) {
42
+ function apkOf(release, options) {
32
43
  const assets = Array.isArray(release.assets)
33
44
  ? release.assets.filter(isObject)
34
45
  : [];
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
- };
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;
44
58
  }
45
- function toLatestRelease(release) {
46
- const apk = apkOf(release);
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);
47
65
  if (!apk)
48
66
  return null;
49
- return { version: versionOf(release), notes: text(release.body), ...apk };
67
+ return { version, notes: notesOf(release), ...apk };
50
68
  }
51
69
  function githubHeaders({ token }) {
52
70
  return {
@@ -106,14 +124,14 @@ async function fetchGithubJson(url, ref) {
106
124
  export async function fetchLatestRelease(options) {
107
125
  if (!options.includePrereleases) {
108
126
  const data = await fetchGithubJson(`${releasesUrl(options)}/latest`, options);
109
- return isObject(data) ? toLatestRelease(data) : null;
127
+ return isObject(data) ? toLatestRelease(data, options) : null;
110
128
  }
111
129
  const data = await fetchGithubJson(`${releasesUrl(options)}?per_page=${String(PRERELEASE_SCAN)}`, options);
112
130
  let latest = null;
113
131
  for (const release of releasesIn(data)) {
114
132
  if (release.draft === true)
115
133
  continue;
116
- const candidate = toLatestRelease(release);
134
+ const candidate = toLatestRelease(release, options);
117
135
  if (candidate &&
118
136
  (!latest || compareVersions(candidate.version, latest.version) > 0)) {
119
137
  latest = candidate;
@@ -134,7 +152,7 @@ export async function fetchReleaseHistory(ref) {
134
152
  .filter((release) => release.draft !== true)
135
153
  .map((release) => ({
136
154
  version: versionOf(release),
137
- notes: text(release.body),
155
+ notes: notesOf(release),
138
156
  publishedAt: text(release.published_at),
139
157
  }));
140
158
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forthtilliath/expo-release-updates",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "license": "MIT",
@@ -34,17 +34,17 @@
34
34
  }
35
35
  },
36
36
  "dependencies": {
37
- "@forthtilliath/ts-kit": "^0.14.0"
37
+ "@forthtilliath/ts-kit": "^0.15.0"
38
38
  },
39
39
  "devDependencies": {
40
- "@vitest/coverage-v8": "5.0.2",
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.2",
46
- "@forthtilliath/eslint-config": "0.9.0",
45
+ "vitest": "^5.0.5",
47
46
  "@forthtilliath/typescript-config": "0.1.1",
47
+ "@forthtilliath/eslint-config": "0.10.0",
48
48
  "@forthtilliath/test-kit": "0.4.0"
49
49
  },
50
50
  "scripts": {