@livx.cc/appwrap 0.28.1 → 0.29.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/package.json +1 -1
- package/runtime/app/shell/background-worker.android.ts +47 -0
- package/runtime/app/shell/background-worker.ios.ts +5 -0
- package/runtime/app/shell/background-worker.ts +7 -0
- package/runtime/app/shell/handlers-background.ts +10 -42
- package/src/cli.ts +7 -22
- package/src/config.ts +6 -2
- package/src/derive.ts +50 -0
- package/templates/ci/fastlane/Fastfile +7 -1
- package/templates/ci/github/workflows/appwrap-pr.yml +3 -3
- package/templates/ci/github/workflows/appwrap-release-android.yml +2 -2
- package/templates/ci/github/workflows/appwrap-release-ios.yml +2 -2
package/package.json
CHANGED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Utils } from '@nativescript/core';
|
|
2
|
+
import { runHeadless, finishRun } from './handlers-background';
|
|
3
|
+
|
|
4
|
+
// androidx (WorkManager) isn't in @nativescript/types-android — it's an AndroidX library, not the
|
|
5
|
+
// platform SDK — so it stays `any`. `java` resolves from the SDK typings; `NativeClass`/`JavaProxy`
|
|
6
|
+
// are ambient NativeScript runtime globals.
|
|
7
|
+
declare const androidx: any;
|
|
8
|
+
declare const java: any;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* WorkManager headless Worker — created by WorkManager on a background launch. `doWork()` posts to the
|
|
12
|
+
* MAIN looper (the WebView must be built + driven on the UI thread), runs the headless WebView loop for
|
|
13
|
+
* the input task id, and blocks the worker thread on a `CountDownLatch` until `backgroundTask.finish`
|
|
14
|
+
* (or a timeout). Returns success/failure; WorkManager handles periodic rescheduling.
|
|
15
|
+
*
|
|
16
|
+
* ANDROID-ONLY (`.android.ts`): a top-level `@JavaProxy` / `extends androidx.work.Worker` class
|
|
17
|
+
* dereferences Android-only globals at module load. In a SHARED file that evaluates on iOS too, that
|
|
18
|
+
* throws during ES-module instantiation → hard launch crash. Isolating it here keeps it off iOS.
|
|
19
|
+
* ⚠ DEVICE-UNVERIFIED — compiles only (see handlers-background.ts header).
|
|
20
|
+
*/
|
|
21
|
+
@NativeClass()
|
|
22
|
+
@JavaProxy('cc.livx.appwrap.AppwrapBackgroundWorker')
|
|
23
|
+
export class AppwrapBackgroundWorker extends androidx.work.Worker {
|
|
24
|
+
constructor(context: any, params: any) {
|
|
25
|
+
super(context, params);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
doWork(): any {
|
|
29
|
+
const id = this.getInputData().getString('appwrap.taskId') ?? '';
|
|
30
|
+
if (!id) return androidx.work.ListenableWorker.Result.failure();
|
|
31
|
+
|
|
32
|
+
const latch = new java.util.concurrent.CountDownLatch(1);
|
|
33
|
+
const result = { success: false };
|
|
34
|
+
Utils.dispatchToMainThread(() => {
|
|
35
|
+
runHeadless(id)
|
|
36
|
+
.then((ok: boolean) => { result.success = ok; latch.countDown(); })
|
|
37
|
+
.catch(() => { latch.countDown(); });
|
|
38
|
+
});
|
|
39
|
+
// Block the worker thread (bounded — below the WorkManager 10-min ceiling) until the JS handler
|
|
40
|
+
// finishes. A timeout returns failure so WorkManager retries on its schedule.
|
|
41
|
+
const completed = latch.await(9, java.util.concurrent.TimeUnit.MINUTES);
|
|
42
|
+
if (!completed) finishRun(id, false);
|
|
43
|
+
return completed && result.success
|
|
44
|
+
? androidx.work.ListenableWorker.Result.success()
|
|
45
|
+
: androidx.work.ListenableWorker.Result.failure();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// iOS stub. The WorkManager Worker is Android-only; handlers-background.ts STATICALLY imports
|
|
2
|
+
// `AppwrapBackgroundWorker`, so the name must resolve on iOS too — but it must carry NO `@JavaProxy` /
|
|
3
|
+
// `androidx` references (those are undefined on iOS and would crash ES-module instantiation at launch).
|
|
4
|
+
// registerAndroid() is the only consumer and never runs on iOS, so this value is never dereferenced.
|
|
5
|
+
export const AppwrapBackgroundWorker: any = undefined;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Base / iOS module for `./background-worker`. The WorkManager Worker is Android-only; the real
|
|
2
|
+
// `@JavaProxy` class lives in `background-worker.android.ts` (NS resolves the `.android.ts` override on
|
|
3
|
+
// Android). This base resolves for plain `tsc` (which doesn't know NS platform suffixes) AND serves as
|
|
4
|
+
// the iOS runtime impl — it must carry NO `@JavaProxy` / `androidx` references (undefined on iOS, they'd
|
|
5
|
+
// crash ES-module instantiation at launch). registerAndroid() — the only consumer — never runs on iOS,
|
|
6
|
+
// so this value is never dereferenced there.
|
|
7
|
+
export const AppwrapBackgroundWorker: any = undefined;
|
|
@@ -3,6 +3,11 @@ import { bridge } from './bridge';
|
|
|
3
3
|
import { SHELL_CONFIG } from './config';
|
|
4
4
|
import { setPendingBackgroundTaskId } from './background-context';
|
|
5
5
|
import { CustomWebView } from './custom-webview';
|
|
6
|
+
// Android-only WorkManager Worker (@JavaProxy + `extends androidx.work.Worker`). Kept in a `.android.ts`
|
|
7
|
+
// file so it's NEVER evaluated on iOS — a top-level Android native class in a shared module dereferences
|
|
8
|
+
// `@JavaProxy`/`androidx` at module load, which are undefined on iOS → the whole ES module graph fails to
|
|
9
|
+
// instantiate → hard launch crash. iOS resolves the `.ios.ts` stub; registerAndroid() is the only user.
|
|
10
|
+
import { AppwrapBackgroundWorker } from './background-worker';
|
|
6
11
|
|
|
7
12
|
// BackgroundTasks (iOS), NSDate, and the `android`/`java` namespaces resolve from the full SDK
|
|
8
13
|
// (@nativescript/types-ios + types-android) — no declares needed.
|
|
@@ -58,7 +63,7 @@ const pendingRuns = new Map<string, PendingRun>();
|
|
|
58
63
|
/** Build an offscreen WebView, attach the bridge, and load the app so its handshake reports `id`. The
|
|
59
64
|
* returned promise resolves when the JS handler calls `backgroundTask.finish` (or `abort()` fires).
|
|
60
65
|
* REUSES `CustomWebView` (scheme handler + bridge injection) — no duplicated transport. */
|
|
61
|
-
function runHeadless(id: string): Promise<boolean> {
|
|
66
|
+
export function runHeadless(id: string): Promise<boolean> {
|
|
62
67
|
return new Promise<boolean>((resolve) => {
|
|
63
68
|
setPendingBackgroundTaskId(id); // the next handshake reports this wake id
|
|
64
69
|
const webView = new CustomWebView();
|
|
@@ -109,7 +114,7 @@ function loadAppInto(webView: CustomWebView, id: string, attempt = 0): void {
|
|
|
109
114
|
|
|
110
115
|
/** Resolve an in-flight headless run (called by `backgroundTask.finish`, the safety abort, or a load
|
|
111
116
|
* failure). Tears the offscreen WebView's bridge attachment down. Idempotent. */
|
|
112
|
-
function finishRun(id: string, success: boolean): void {
|
|
117
|
+
export function finishRun(id: string, success: boolean): void {
|
|
113
118
|
const run = pendingRuns.get(id);
|
|
114
119
|
if (!run) return;
|
|
115
120
|
pendingRuns.delete(id);
|
|
@@ -222,7 +227,7 @@ function registerAndroid(): void {
|
|
|
222
227
|
// WorkManager periodic floor is 15 min; clamp a smaller hint up so enqueue doesn't reject it.
|
|
223
228
|
const ms = Math.max(15 * 60_000, Number(p?.minIntervalMs ?? 15 * 60_000));
|
|
224
229
|
const builder = new androidx.work.PeriodicWorkRequest.Builder(
|
|
225
|
-
AppwrapBackgroundWorker.class,
|
|
230
|
+
(AppwrapBackgroundWorker as any).class,
|
|
226
231
|
ms, java.util.concurrent.TimeUnit.MILLISECONDS
|
|
227
232
|
);
|
|
228
233
|
// The id rides as input data → the Worker reads it to drive the matching JS handler.
|
|
@@ -249,42 +254,5 @@ function registerAndroid(): void {
|
|
|
249
254
|
});
|
|
250
255
|
}
|
|
251
256
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
* MAIN looper (the WebView must be built + driven on the UI thread), runs the headless WebView loop for
|
|
255
|
-
* the input task id, and blocks the worker thread on a `CountDownLatch` until `backgroundTask.finish`
|
|
256
|
-
* (or a timeout). Returns success/failure; WorkManager handles periodic rescheduling.
|
|
257
|
-
*
|
|
258
|
-
* ⚠ DEVICE-UNVERIFIED — compiles only (see the file header).
|
|
259
|
-
*/
|
|
260
|
-
@NativeClass()
|
|
261
|
-
@JavaProxy('cc.livx.appwrap.AppwrapBackgroundWorker')
|
|
262
|
-
export class AppwrapBackgroundWorker extends androidx.work.Worker {
|
|
263
|
-
constructor(context: any, params: any) {
|
|
264
|
-
super(context, params);
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
doWork(): any {
|
|
268
|
-
const id = this.getInputData().getString('appwrap.taskId') ?? '';
|
|
269
|
-
if (!id) return androidx.work.ListenableWorker.Result.failure();
|
|
270
|
-
|
|
271
|
-
const latch = new java.util.concurrent.CountDownLatch(1);
|
|
272
|
-
const result = { success: false };
|
|
273
|
-
Utils.dispatchToMainThread(() => {
|
|
274
|
-
runHeadless(id)
|
|
275
|
-
.then((ok: boolean) => { result.success = ok; latch.countDown(); })
|
|
276
|
-
.catch(() => { latch.countDown(); });
|
|
277
|
-
});
|
|
278
|
-
// Block the worker thread (bounded — below the WorkManager 10-min ceiling) until the JS handler
|
|
279
|
-
// finishes. A timeout returns failure so WorkManager retries on its schedule.
|
|
280
|
-
const completed = latch.await(9, java.util.concurrent.TimeUnit.MINUTES);
|
|
281
|
-
if (!completed) finishRun(id, false);
|
|
282
|
-
return completed && result.success
|
|
283
|
-
? androidx.work.ListenableWorker.Result.success()
|
|
284
|
-
: androidx.work.ListenableWorker.Result.failure();
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
// Reference the Worker class so the bundler/NS metadata retains the JavaProxy (mirrors how the FCM
|
|
289
|
-
// service is kept alive via its import side-effect). Without a reference the class can be tree-shaken.
|
|
290
|
-
void AppwrapBackgroundWorker;
|
|
257
|
+
// The WorkManager headless Worker (@JavaProxy `AppwrapBackgroundWorker`) lives in
|
|
258
|
+
// `background-worker.android.ts` — see the import at the top of this file for WHY it can't be here.
|
package/src/cli.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
androidScreenOrientation,
|
|
25
25
|
iosOrientations,
|
|
26
26
|
mergeManifest,
|
|
27
|
+
resolveBuildNumber,
|
|
27
28
|
stampAndroidOrientation,
|
|
28
29
|
stampAndroidQueries,
|
|
29
30
|
stampPlistBackgroundTasks,
|
|
@@ -45,31 +46,15 @@ const execErrText = (e: unknown): string => {
|
|
|
45
46
|
return `${err.stdout ?? ''}${err.stderr ?? ''}`;
|
|
46
47
|
};
|
|
47
48
|
|
|
48
|
-
/** Marketing version → a monotonic integer build (0.2.1 → 201; 1.4.12 → 10412). Stable & increasing
|
|
49
|
-
* across semver bumps so store re-uploads are always accepted without a manual bump. */
|
|
50
|
-
function deriveBuild(version: string): number {
|
|
51
|
-
const [maj = 0, min = 0, patch = 0] = version.split('.').map((n) => parseInt(n, 10) || 0);
|
|
52
|
-
return maj * 10000 + min * 100 + patch;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
49
|
/**
|
|
56
|
-
* Resolved monotonic build number (iOS CFBundleVersion / Android versionCode).
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* of
|
|
50
|
+
* Resolved monotonic build number (iOS CFBundleVersion / Android versionCode). Thin env wrapper over
|
|
51
|
+
* the pure `resolveBuildNumber` (in derive.ts, where it unit-tests). Precedence: `APPWRAP_BUILD_NUMBER`
|
|
52
|
+
* env (CI run #) > explicit numeric `cfg.buildNumber` > named strategy ('timestamp'|'epoch') > derived
|
|
53
|
+
* from version. The env override gives CI a monotonic, collision-free build for free; the derived
|
|
54
|
+
* default is CONSTANT per version, so repeat uploads of one marketing version would 409 without it.
|
|
61
55
|
*/
|
|
62
56
|
function buildNumberOf(cfg: AppwrapConfig): number {
|
|
63
|
-
|
|
64
|
-
if (env != null && env !== '') {
|
|
65
|
-
const n = parseInt(env, 10);
|
|
66
|
-
if (!Number.isNaN(n)) return n;
|
|
67
|
-
}
|
|
68
|
-
if (cfg.buildNumber != null) {
|
|
69
|
-
const n = parseInt(String(cfg.buildNumber), 10);
|
|
70
|
-
if (!Number.isNaN(n)) return n;
|
|
71
|
-
}
|
|
72
|
-
return deriveBuild(cfg.version);
|
|
57
|
+
return resolveBuildNumber(cfg, process.env.APPWRAP_BUILD_NUMBER);
|
|
73
58
|
}
|
|
74
59
|
|
|
75
60
|
const IOS_PERMISSION_KEYS: Record<string, string[]> = {
|
package/src/config.ts
CHANGED
|
@@ -113,8 +113,12 @@ export interface AppwrapConfig {
|
|
|
113
113
|
>;
|
|
114
114
|
/** Monotonic build identifier. Stores reject a re-upload unless this is HIGHER than the last:
|
|
115
115
|
* iOS `CFBundleVersion`, Android `versionCode` (the marketing `version` stays the user-facing
|
|
116
|
-
* string). Default: an integer derived from `version` (0.2.1 → 201). Set
|
|
117
|
-
* run
|
|
116
|
+
* string). Default: an integer derived from `version` (0.2.1 → 201). Set an explicit number from a
|
|
117
|
+
* CI run for fleet builds of one marketing version, OR a named strategy string (resolved by the
|
|
118
|
+
* framework so it can't drift across branches):
|
|
119
|
+
* - `'timestamp'` — YYMMDDHHMM UTC (e.g. 2606221405). iOS-ONLY: exceeds Android's versionCode cap.
|
|
120
|
+
* - `'epoch'` — unix seconds. Android-safe.
|
|
121
|
+
* (`APPWRAP_BUILD_NUMBER` env always wins; an unknown string falls back to the derived default.) */
|
|
118
122
|
buildNumber?: string | number;
|
|
119
123
|
/** iOS export-compliance. `ITSAppUsesNonExemptEncryption` — stamped `false` by default (skips the
|
|
120
124
|
* per-upload prompt). Set `true` only if the app uses non-exempt encryption. */
|
package/src/derive.ts
CHANGED
|
@@ -47,6 +47,56 @@ export function mergeManifest<
|
|
|
47
47
|
return cfg;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/** Marketing version → a monotonic integer build (0.2.1 → 201; 1.4.12 → 10412). Stable & increasing
|
|
51
|
+
* across semver bumps so store re-uploads are always accepted without a manual bump. */
|
|
52
|
+
export function deriveBuild(version: string): number {
|
|
53
|
+
const [maj = 0, min = 0, patch = 0] = version.split('.').map((n) => parseInt(n, 10) || 0);
|
|
54
|
+
return maj * 10000 + min * 100 + patch;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Named build-number strategies. Live ONCE here so apps opt into a name instead of copying the
|
|
58
|
+
* stamping logic into their config (which drifts across branches). All return a single integer.
|
|
59
|
+
* - `timestamp`: human-readable YYMMDDHHMM (UTC), e.g. 2026-06-22 14:05 → 2606221405. ≈2.6e9, ≤ the
|
|
60
|
+
* iOS CFBundleVersion UInt32 cap (4294967295) until ~2042 — but it EXCEEDS Android versionCode's
|
|
61
|
+
* 2.1e9 cap, so this strategy is iOS-ONLY.
|
|
62
|
+
* - `epoch`: unix seconds (Math.floor(Date.now()/1000)), ≈1.78e9 — Android-safe. */
|
|
63
|
+
const BUILD_STRATEGIES: Record<string, (now: Date) => number> = {
|
|
64
|
+
timestamp: (now) => {
|
|
65
|
+
const p = (n: number) => String(n).padStart(2, '0');
|
|
66
|
+
const yy = now.getUTCFullYear() % 100;
|
|
67
|
+
return Number(
|
|
68
|
+
`${p(yy)}${p(now.getUTCMonth() + 1)}${p(now.getUTCDate())}${p(now.getUTCHours())}${p(now.getUTCMinutes())}`
|
|
69
|
+
);
|
|
70
|
+
},
|
|
71
|
+
epoch: (now) => Math.floor(now.getTime() / 1000),
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Resolve the monotonic build number (iOS CFBundleVersion / Android versionCode). Pure: the CLI
|
|
76
|
+
* passes the `APPWRAP_BUILD_NUMBER` env value in (no process.env read here, so it unit-tests).
|
|
77
|
+
* Precedence: env (CI run #) > explicit numeric `buildNumber` > named strategy > derived from version.
|
|
78
|
+
* An unrecognized strategy string falls back to `deriveBuild` (never crashes).
|
|
79
|
+
*/
|
|
80
|
+
export function resolveBuildNumber(
|
|
81
|
+
cfg: { version: string; buildNumber?: string | number },
|
|
82
|
+
envBuildNumber?: string,
|
|
83
|
+
now: Date = new Date()
|
|
84
|
+
): number {
|
|
85
|
+
if (envBuildNumber != null && envBuildNumber !== '') {
|
|
86
|
+
const n = parseInt(envBuildNumber, 10);
|
|
87
|
+
if (!Number.isNaN(n)) return n;
|
|
88
|
+
}
|
|
89
|
+
if (cfg.buildNumber != null) {
|
|
90
|
+
// A numeric value (number or numeric string) is the literal build number.
|
|
91
|
+
const n = parseInt(String(cfg.buildNumber), 10);
|
|
92
|
+
if (!Number.isNaN(n)) return n;
|
|
93
|
+
// Non-numeric → a strategy NAME. Map it, or fall back to deriveBuild for an unknown string.
|
|
94
|
+
const strategy = BUILD_STRATEGIES[String(cfg.buildNumber).trim()];
|
|
95
|
+
if (strategy) return strategy(now);
|
|
96
|
+
}
|
|
97
|
+
return deriveBuild(cfg.version);
|
|
98
|
+
}
|
|
99
|
+
|
|
50
100
|
/**
|
|
51
101
|
* Collapse a PWA web-manifest `orientation` value to our three states. The manifest spec allows
|
|
52
102
|
* `portrait`/`landscape` plus the `-primary`/`-secondary`/`*-up`/`natural` variants — we don't model
|
|
@@ -46,7 +46,13 @@ platform :ios do
|
|
|
46
46
|
|
|
47
47
|
# Wait for App Store Connect to finish processing and FAIL if it doesn't — Apple can accept the
|
|
48
48
|
# upload yet silently drop the build (e.g. an out-of-range CFBundleVersion). skip:true hides that.
|
|
49
|
-
|
|
49
|
+
# APPWRAP_TF_WAIT_TIMEOUT (seconds) tunes the wait: real builds can exceed 30 min under Apple
|
|
50
|
+
# processing load and crash the lane (BuildWatcher exceeded) — default to 1h, raise it if needed.
|
|
51
|
+
# Guard BOTH unset (nil) and empty/blank env: nil.to_i and "".to_i are 0, which would crash the
|
|
52
|
+
# lane instantly (BuildWatcher exceeded '0'). Only a positive integer overrides the 3600 default.
|
|
53
|
+
tf_wait_env = ENV['APPWRAP_TF_WAIT_TIMEOUT'].to_s.strip
|
|
54
|
+
tf_wait_timeout = tf_wait_env.to_i > 0 ? tf_wait_env.to_i : 3600
|
|
55
|
+
upload_to_testflight(api_key: api_key, skip_waiting_for_build_processing: false, wait_processing_timeout_duration: tf_wait_timeout)
|
|
50
56
|
end
|
|
51
57
|
end
|
|
52
58
|
|
|
@@ -7,7 +7,7 @@ jobs:
|
|
|
7
7
|
web:
|
|
8
8
|
runs-on: ubuntu-latest
|
|
9
9
|
steps:
|
|
10
|
-
- uses: actions/checkout@
|
|
10
|
+
- uses: actions/checkout@v5
|
|
11
11
|
- uses: oven-sh/setup-bun@v2
|
|
12
12
|
- run: bun install
|
|
13
13
|
- run: bun test || echo "no tests"
|
|
@@ -17,9 +17,9 @@ jobs:
|
|
|
17
17
|
runs-on: macos-15
|
|
18
18
|
needs: web
|
|
19
19
|
steps:
|
|
20
|
-
- uses: actions/checkout@
|
|
20
|
+
- uses: actions/checkout@v5
|
|
21
21
|
- uses: oven-sh/setup-bun@v2
|
|
22
|
-
- uses: actions/setup-node@
|
|
22
|
+
- uses: actions/setup-node@v5
|
|
23
23
|
with: { node-version: 22 }
|
|
24
24
|
- run: bun install && bun run build
|
|
25
25
|
# Pin global tools — unpinned installs can grab a breaking release mid-flight. Bump deliberately.
|
|
@@ -22,9 +22,9 @@ jobs:
|
|
|
22
22
|
play:
|
|
23
23
|
runs-on: ubuntu-latest
|
|
24
24
|
steps:
|
|
25
|
-
- uses: actions/checkout@
|
|
25
|
+
- uses: actions/checkout@v5
|
|
26
26
|
- uses: oven-sh/setup-bun@v2
|
|
27
|
-
- uses: actions/setup-node@
|
|
27
|
+
- uses: actions/setup-node@v5
|
|
28
28
|
with: { node-version: 22 }
|
|
29
29
|
- uses: actions/setup-java@v4
|
|
30
30
|
with: { distribution: temurin, java-version: 17 }
|
|
@@ -24,12 +24,12 @@ jobs:
|
|
|
24
24
|
testflight:
|
|
25
25
|
runs-on: macos-15
|
|
26
26
|
steps:
|
|
27
|
-
- uses: actions/checkout@
|
|
27
|
+
- uses: actions/checkout@v5
|
|
28
28
|
# Apple requires the iOS 26 SDK (Xcode 26+) for uploads — runners may default to older Xcode.
|
|
29
29
|
- uses: maxim-lobanov/setup-xcode@v1
|
|
30
30
|
with: { xcode-version: latest-stable }
|
|
31
31
|
- uses: oven-sh/setup-bun@v2
|
|
32
|
-
- uses: actions/setup-node@
|
|
32
|
+
- uses: actions/setup-node@v5
|
|
33
33
|
with: { node-version: 22 }
|
|
34
34
|
- run: bun install && bun run build
|
|
35
35
|
# Pin global tools — unpinned installs can grab a breaking release mid-flight. Bump deliberately.
|