@forthtilliath/expo-release-updates 0.8.0 → 0.10.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.
@@ -93,9 +95,9 @@ if (release && isUpdateAvailable(currentVersion, release.version)) {
93
95
  }
94
96
  ```
95
97
 
96
- ### `fetchReleaseHistory({ owner, repo, limit?, token?, timeoutMs?, signal? })`
98
+ ### `fetchReleaseHistory({ owner, repo, limit?, includePrereleases?, token?, timeoutMs?, signal? })`
97
99
 
98
- Fetches the most recent published releases (version, notes, publish date), most recent first — draft releases, visible with a token, are left out. `limit` defaults to `10` (clamped to GitHub's 1–100) — useful for a "release history" / "what's new" screen.
100
+ Fetches the most recent published releases (version, notes, publish date), most recent first — draft releases, visible with a token, are left out. `limit` defaults to `10` (clamped to GitHub's 1–100) — useful for a "release history" / "what's new" screen. Pre-releases are listed too, unless `includePrereleases: false` (pass the same value as to `fetchLatestRelease`, so users who don't get test versions don't see them listed either; fewer than `limit` entries may then come back).
99
101
 
100
102
  ```ts
101
103
  const history = await fetchReleaseHistory({
@@ -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;
@@ -62,9 +71,12 @@ export declare function fetchLatestRelease(options: FetchLatestReleaseOptions):
62
71
  /**
63
72
  * Fetches the most recent published releases for a repo (version, notes,
64
73
  * publish date), most recent first — useful for a "release history" screen.
65
- * Draft releases (only visible with a token) are left out.
74
+ * Draft releases (only visible with a token) are left out, and so are
75
+ * pre-releases with `includePrereleases: false` (the list may then hold fewer
76
+ * than `limit` entries).
66
77
  */
67
78
  export declare function fetchReleaseHistory(ref: GithubRepoRef & {
68
79
  limit?: number;
80
+ includePrereleases?: boolean;
69
81
  }): Promise<ReleaseHistoryEntry[]>;
70
82
  //# sourceMappingURL=githubReleases.d.ts.map
@@ -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;AAiCD;;;;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;;;;;;GAMG;AACH,wBAAsB,mBAAmB,CACvC,GAAG,EAAE,aAAa,GAAG;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,kBAAkB,CAAC,EAAE,OAAO,CAAA;CAAE,GACpE,OAAO,CAAC,mBAAmB,EAAE,CAAC,CAkBhC"}
@@ -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;
@@ -124,17 +142,20 @@ export async function fetchLatestRelease(options) {
124
142
  /**
125
143
  * Fetches the most recent published releases for a repo (version, notes,
126
144
  * publish date), most recent first — useful for a "release history" screen.
127
- * Draft releases (only visible with a token) are left out.
145
+ * Draft releases (only visible with a token) are left out, and so are
146
+ * pre-releases with `includePrereleases: false` (the list may then hold fewer
147
+ * than `limit` entries).
128
148
  */
129
149
  export async function fetchReleaseHistory(ref) {
130
- const { limit = 10 } = ref;
150
+ const { limit = 10, includePrereleases = true } = ref;
131
151
  const perPage = Math.min(Math.max(Math.trunc(limit), 1), MAX_PER_PAGE);
132
152
  const data = await fetchGithubJson(`${releasesUrl(ref)}?per_page=${String(perPage)}`, ref);
133
153
  return releasesIn(data)
134
- .filter((release) => release.draft !== true)
154
+ .filter((release) => release.draft !== true &&
155
+ (includePrereleases || release.prerelease !== true))
135
156
  .map((release) => ({
136
157
  version: versionOf(release),
137
- notes: text(release.body),
158
+ notes: notesOf(release),
138
159
  publishedAt: text(release.published_at),
139
160
  }));
140
161
  }
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.10.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.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",
47
- "@forthtilliath/typescript-config": "0.1.1",
48
- "@forthtilliath/test-kit": "0.4.0"
45
+ "vitest": "^5.0.5",
46
+ "@forthtilliath/eslint-config": "0.10.0",
47
+ "@forthtilliath/test-kit": "0.4.0",
48
+ "@forthtilliath/typescript-config": "0.1.1"
49
49
  },
50
50
  "scripts": {
51
51
  "lint": "eslint . --max-warnings 0",