Publish updates to the Gitea package registry, and build the installer on macOS
The dev machine is now a laptop, so the release path had to stop assuming Windows and a reachable network share. - tools/build-win.cjs: build the Windows x64 installer from macOS or Windows. electron-builder fetches its own mac wine build, so the only real trap is architecture: a plain `electron-builder --win` on Apple Silicon silently produces an arm64 installer the stage PC cannot run. This pins --x64 and strips the CSC_* signing vars (an EMPTY WIN_CSC_LINK aborts the build). - tools/publish-update.cjs: an http(s) destination now uploads to a Gitea generic package registry. Not a Gitea release: this server caps release attachments at 100 MB and disallows .exe, while the package registry takes the 750 MB installer and serves it anonymously — which the updater requires, since it sends no credentials. Installer first, manifest last, so a TV checking mid-publish never sees a manifest naming a file that is not there yet. - tools/release.cjs: bump, build and publish in one command. - server.js: honour "check for updates automatically when the app starts". The setting was stored but never acted on. It checks only — installing restarts the app, which must stay a deliberate tap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+99
-64
@@ -3,6 +3,16 @@
|
||||
This folder is the complete source tree of the Bible App as it runs on the stage TV (version in
|
||||
`package.json`). Everything the installer build needs is here. Nothing the build produces is.
|
||||
|
||||
The short version, once set up:
|
||||
|
||||
```bash
|
||||
npm run release -- "What changed in this version"
|
||||
git commit -am "Release <version>" && git push
|
||||
```
|
||||
|
||||
That bumps the version, builds the Windows installer, uploads it to the church Gitea server, and the
|
||||
TV picks it up from **Settings › Updates**. No .exe to hand-carry.
|
||||
|
||||
## 1. What is (and is not) in this folder
|
||||
|
||||
Included — everything the build and the app need:
|
||||
@@ -13,115 +23,140 @@ Included — everything the build and the app need:
|
||||
`dict/`, `fonts/`, `Notes/`, `Announcements/`, `Graphics/` (the defaults that ship inside the installer).
|
||||
- Vendored runtimes that ship inside the installer: `vendor/whisper`, `vendor/upscale`, `vendor/redist`,
|
||||
`vendor/llama` (the llama-server binaries — NOT the models), `vendor/fontawesome`.
|
||||
- Build inputs: `package.json`, `package-lock.json`, `build/installer.nsh`, `tools/` (`build-full-installer.cjs`,
|
||||
`publish-update.cjs`, the SFX stub in `tools/sfx`, `mock-propresenter.cjs`), `build-installer.bat`.
|
||||
- Build inputs: `package.json`, `package-lock.json`, `build/installer.nsh`, `tools/` (`build-win.cjs`,
|
||||
`release.cjs`, `publish-update.cjs`, `build-full-installer.cjs`, the SFX stub in `tools/sfx`,
|
||||
`mock-propresenter.cjs`), `build-installer.bat`.
|
||||
- The `*.md` notes at the root (small; project history worth keeping).
|
||||
|
||||
Left out on purpose (would only take space):
|
||||
|
||||
| Left out | Why |
|
||||
|---|---|
|
||||
| `dist-installer/` (57 GB), `dist/` | every old installer ever built; the build recreates them |
|
||||
| `.git/` (32 GB) | the old history had installers and models committed into it — start a fresh repo (below) |
|
||||
| `dist-installer/`, `dist/` | every installer ever built; the build recreates them |
|
||||
| `node_modules/` (640 MB) | `npm ci` recreates it exactly from `package-lock.json` |
|
||||
| `vendor/llama/*.gguf` (8 GB) | AI models; the regular installer never carries them (see §5) |
|
||||
| `.ebcache/`, `archive/`, `*.log`, `server-port.txt` | build cache, retired scratch files, runtime files |
|
||||
| `Dictionary/**/*.zip`, the Strong's XML folder | source archives; the app reads the converted JSON beside them |
|
||||
|
||||
Size of this folder: about 1.3 GB, of which ~700 MB is the vendored runtimes and ~400 MB is Bible data.
|
||||
Size of this folder: about 1.2 GB, of which ~700 MB is the vendored runtimes and ~400 MB is Bible data.
|
||||
|
||||
## 2. Build machine prerequisites (the laptop)
|
||||
## 2. Build machine prerequisites
|
||||
|
||||
- Windows 10/11 x64.
|
||||
- Node.js 22 LTS (this tree was built with Node 22.18 / npm 10.9). Install from nodejs.org.
|
||||
- Internet the first time: `npm ci` downloads Electron and electron-builder pulls NSIS and its
|
||||
winCodeSign helper into `%LOCALAPPDATA%\electron-builder\Cache` (a few hundred MB, once).
|
||||
- No code-signing certificate is used. The build must be told so (step 3).
|
||||
**The Windows installer builds on macOS as well as on Windows.** electron-builder downloads its own
|
||||
mac build of wine for the few Windows-only steps, so the laptop produces the exact same installer the
|
||||
stage PC installs. Nothing has to be done on the Windows machine any more.
|
||||
|
||||
## 3. Build the regular installer
|
||||
- macOS (Apple Silicon or Intel), or Windows 10/11 x64.
|
||||
- Node.js 22 LTS. On macOS: `brew install node@22`. On Windows: nodejs.org.
|
||||
- Internet the first time: `npm ci` downloads Electron, and electron-builder pulls NSIS, winCodeSign
|
||||
and (on macOS) wine into its cache — a few hundred MB, once.
|
||||
- No code-signing certificate is used, and `tools/build-win.cjs` sets the environment up accordingly.
|
||||
|
||||
```bat
|
||||
cd bible-app-source
|
||||
npm ci
|
||||
set CSC_IDENTITY_AUTO_DISCOVERY=false
|
||||
npx electron-builder --win
|
||||
## 3. Build the installer
|
||||
|
||||
```bash
|
||||
npm ci # first time only
|
||||
npm run build:win
|
||||
```
|
||||
|
||||
Output: `dist-installer\Bible App Setup <version>.exe` (about 750 MB).
|
||||
Output: `dist-installer/Bible App Setup <version>.exe` (about 750 MB).
|
||||
|
||||
Notes:
|
||||
|
||||
- Bump `"version"` in `package.json` before every build you intend to ship. The TV's updater compares
|
||||
versions numerically and only offers a HIGHER version.
|
||||
- `build-installer.bat` does the same as the commands above and also bumps the patch version and clears
|
||||
the electron-builder cache first. Either way works.
|
||||
- If `WIN_CSC_LINK` / `CSC_LINK` / `CSC_KEY_PASSWORD` / `CSC_KEYCHAIN` exist in your environment they must be
|
||||
REMOVED, not set to empty — electron-builder aborts on an empty `WIN_CSC_LINK`
|
||||
("Env WIN_CSC_LINK is not correct"). In PowerShell: `Remove-Item Env:WIN_CSC_LINK`.
|
||||
- If the build fails while extracting `winCodeSign` (symlink / permission errors), run the terminal as
|
||||
administrator or turn on Windows Developer Mode, delete `%LOCALAPPDATA%\electron-builder\Cache\winCodeSign`,
|
||||
and build again.
|
||||
- `npm run build:win` always builds **x64**. Do not call `npx electron-builder --win` directly on an
|
||||
Apple Silicon Mac: electron-builder defaults to the host architecture and will quietly hand you an
|
||||
arm64 build that the stage PC cannot run.
|
||||
- Bump `"version"` in `package.json` before every build you intend to ship — `npm run release` does it
|
||||
for you. The TV's updater compares versions numerically and only offers a HIGHER version.
|
||||
- If `WIN_CSC_LINK` / `CSC_LINK` / `CSC_KEY_PASSWORD` / `CSC_KEYCHAIN` exist in your environment,
|
||||
`build-win.cjs` removes them for the build (electron-builder aborts on an EMPTY `WIN_CSC_LINK`).
|
||||
- On Windows, if the build fails while extracting `winCodeSign` (symlink / permission errors), run the
|
||||
terminal as administrator or turn on Developer Mode, delete
|
||||
`%LOCALAPPDATA%\electron-builder\Cache\winCodeSign`, and build again.
|
||||
|
||||
## 4. Update the TVs remotely (no cable, no remote desktop)
|
||||
|
||||
Each TV has **Settings › Updates › "Update folder or address"** — a network share (`\\SERVER\share\bible-app`),
|
||||
a folder, or an `http://` address — plus "Check for updates automatically when the app starts". The TV reads
|
||||
`<source>/latest.json` there, compares versions, then downloads and runs the installer. The app closes,
|
||||
installs, reopens; notes, graphics, annotations and settings are kept.
|
||||
Each TV has **Settings › Updates › "Update folder or address"** plus "Check for updates automatically
|
||||
when the app starts". The TV reads `<source>/latest.json`, compares versions, then downloads and runs
|
||||
the installer. The app closes, installs, reopens; notes, graphics, annotations and settings are kept.
|
||||
|
||||
From the laptop, after a build:
|
||||
The church's update source is the Gitea **package registry**:
|
||||
|
||||
```bat
|
||||
npm run publish-update -- "\\SERVER\share\bible-app" "what changed in this version"
|
||||
```
|
||||
http://chronicles-ab.waterschurch.org:3000/api/packages/christian/generic/bible-app/latest
|
||||
```
|
||||
|
||||
That copies the newest installer into the folder and writes `latest.json` (version, file name, size,
|
||||
sha256, notes). For an `http://` source, upload the `.exe` and `latest.json` to the web server yourself.
|
||||
`npm run release` builds and publishes in one go.
|
||||
Paste that into Settings › Updates on every machine, once. It is also `bibleApp.updateTarget` in
|
||||
`package.json`, which is where the publish script gets it from.
|
||||
|
||||
The TV picks the update up at its next start (auto-check on) or when someone taps **Check for Updates**
|
||||
in Settings. It never installs without that check, so time the publish for after the service.
|
||||
> Why the package registry and not a Gitea *release*? This server caps release attachments at 100 MB
|
||||
> and does not allow `.exe`. The package registry takes the 750 MB installer and — importantly —
|
||||
> serves it to **anonymous** readers, which is what the updater needs: it sends no credentials. The
|
||||
> code repository stays private; only the published installer is readable without a login.
|
||||
|
||||
Ship a version:
|
||||
|
||||
```bash
|
||||
npm run release -- "Faster laser, new popup cards"
|
||||
```
|
||||
|
||||
That bumps the patch version, builds, uploads the installer and `latest.json`, and removes builds
|
||||
older than the previous one. To publish somewhere else instead (a network share, a folder, a web
|
||||
server), pass a destination — the same string you would put in Settings › Updates:
|
||||
|
||||
```bash
|
||||
npm run release -- --dest "\\SERVER\share\bible-app" "Notes"
|
||||
npm run publish-update -- "\\SERVER\share\bible-app" "Notes" # publish an existing build
|
||||
```
|
||||
|
||||
Publishing needs a Gitea token: `GITEA_TOKEN`, or the macOS keychain item `gitea-mcp` that the git
|
||||
tooling already uses. It is never written to disk.
|
||||
|
||||
The TV picks the update up at its next start (auto-check on) or when someone taps **Check for
|
||||
Updates** in Settings. It never installs without that check, so time the publish for after the service.
|
||||
|
||||
## 5. Full Setup (only for a brand-new machine)
|
||||
|
||||
A machine that has never had the app needs the AI model once (about 1.9 GB). That is the one-file
|
||||
"Bible App Full Setup", which drops the model into `%ProgramData%\Bible App\llama-cache` where updates never
|
||||
touch it. To build it, put the model at `vendor/llama/model.gguf` (exactly 2,019,377,696 bytes — copy it from
|
||||
the stage PC: `C:\Users\media\Downloads\bible-app\vendor\llama\model.gguf` or
|
||||
`C:\ProgramData\Bible App\llama-cache\model.gguf`; it is gitignored, so it never enters the repo), build the
|
||||
regular installer, then:
|
||||
"Bible App Full Setup", which drops the model into `%ProgramData%\Bible App\llama-cache` where updates
|
||||
never touch it. To build it, put the model at `vendor/llama/model.gguf` (exactly 2,019,377,696 bytes —
|
||||
copy it from the stage PC: `C:\ProgramData\Bible App\llama-cache\model.gguf`; it is gitignored, so it
|
||||
never enters the repo), build the regular installer, then:
|
||||
|
||||
```bat
|
||||
```bash
|
||||
node tools/build-full-installer.cjs
|
||||
```
|
||||
|
||||
Machines that already have the model keep using the regular installer.
|
||||
|
||||
## 6. Put it in git
|
||||
## 6. Git
|
||||
|
||||
```bat
|
||||
cd bible-app-source
|
||||
git init
|
||||
git add -A
|
||||
git commit -m "Bible App <version> — full source"
|
||||
git remote add origin <your local git server URL>
|
||||
git push -u origin main
|
||||
The tree lives on the church Gitea server:
|
||||
|
||||
```
|
||||
http://chronicles-ab.waterschurch.org:3000/christian/bible-app
|
||||
```
|
||||
|
||||
`.gitignore` keeps the build outputs, `node_modules`, models and logs out; `.gitattributes` stores every
|
||||
file byte-for-byte (the sources are CRLF, and several maintenance scripts match CRLF exactly).
|
||||
`git push` and `git pull` work as usual; the credential is in the macOS keychain. `.gitignore` keeps
|
||||
build outputs, `node_modules`, models and logs out; `.gitattributes` stores every file byte-for-byte
|
||||
(several maintenance scripts match CRLF exactly).
|
||||
|
||||
The first push is about 1.3 GB, mostly the vendored runtimes and Bible JSON. A local Gitea / GitLab
|
||||
handles that fine; if your server has Git LFS enabled, the commented lines in `.gitattributes` move the
|
||||
`vendor/` binaries into LFS (only do that with LFS actually on, or clones get pointer files).
|
||||
Tag what you ship, so a rollback is a checkout rather than an archaeology exercise:
|
||||
|
||||
```bash
|
||||
git tag v<version> && git push --tags
|
||||
```
|
||||
|
||||
If your server has Git LFS enabled, the commented lines in `.gitattributes` move the `vendor/`
|
||||
binaries into LFS (only do that with LFS actually on, or clones get pointer files).
|
||||
|
||||
## 7. Dev-run without building
|
||||
|
||||
```bat
|
||||
```bash
|
||||
npm ci
|
||||
node server.js
|
||||
npm start
|
||||
```
|
||||
|
||||
then open `http://localhost:8080/`. To run a test copy beside a live app, set `BIBLE_APP_PORT=8095` and
|
||||
`BIBLE_APP_DATA_DIR=<some scratch folder>` so it never touches the church's real content in
|
||||
`%APPDATA%\Bible App`.
|
||||
then open `http://localhost:8080/`. This runs the server on macOS and Windows alike. To run a test copy
|
||||
beside a live app, set `BIBLE_APP_PORT=8095` and `BIBLE_APP_DATA_DIR=<some scratch folder>` so it never
|
||||
touches the church's real content.
|
||||
|
||||
+5
-2
@@ -8,8 +8,8 @@
|
||||
"start": "node server.js",
|
||||
"wordnet:fetch": "node tools/fetch-greek-wordnet.js",
|
||||
"build": "electron-builder",
|
||||
"build:win": "electron-builder --win",
|
||||
"release": "electron-builder --win && node tools/publish-update.cjs",
|
||||
"build:win": "node tools/build-win.cjs",
|
||||
"release": "node tools/release.cjs",
|
||||
"publish-update": "node tools/publish-update.cjs",
|
||||
"build:win:dir": "electron-builder --win --dir",
|
||||
"build:installer": "build-installer.bat",
|
||||
@@ -27,6 +27,9 @@
|
||||
],
|
||||
"author": "Bible App Team",
|
||||
"license": "MIT",
|
||||
"bibleApp": {
|
||||
"updateTarget": "http://chronicles-ab.waterschurch.org:3000/api/packages/christian/generic/bible-app/latest"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.bibleapp.desktop",
|
||||
"productName": "Bible App",
|
||||
|
||||
@@ -7823,6 +7823,34 @@ server.listen(PORT, () => {
|
||||
console.error('[OSC] ❌ Failed to start OSC listener:', e && e.message || e);
|
||||
console.error('[OSC] Error stack:', e.stack);
|
||||
}
|
||||
|
||||
/*
|
||||
* "Check for updates automatically when the app starts" (Settings > Updates). Until now that
|
||||
* setting was stored but never acted on, so the TV only learned about a new build when someone
|
||||
* opened Settings and tapped Check for Updates.
|
||||
*
|
||||
* This CHECKS only. The answer lands in lastResult for Settings to show; installing stays a
|
||||
* deliberate tap, because installing closes and reopens the app — which must never happen by
|
||||
* itself in the middle of a service. The delay keeps it clear of the llama/OSC startup rush.
|
||||
*/
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const cfg = readUpdateConfig();
|
||||
if (!cfg.autoCheck || !cfg.source) return;
|
||||
const man = await readUpdateManifest(cfg.source);
|
||||
const latest = String(man.version || '').trim();
|
||||
const current = appVersion();
|
||||
const available = latest && compareVersions(latest, current) > 0;
|
||||
writeUpdateConfig({ lastCheck: Date.now(), lastResult: available ? ('Update ' + latest + ' available') : 'Up to date' });
|
||||
console.log(available
|
||||
? `[UPDATE] Version ${latest} is available (this machine has ${current}) — install it from Settings > Updates.`
|
||||
: `[UPDATE] Up to date (${current}).`);
|
||||
} catch (e) {
|
||||
const msg = String(e && e.message || e);
|
||||
try { writeUpdateConfig({ lastCheck: Date.now(), lastResult: 'Check failed: ' + msg }); } catch (_) {}
|
||||
console.warn('[UPDATE] Startup check failed:', msg);
|
||||
}
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
server.on('error', (error) => {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* build-win.cjs — build the Windows x64 installer. Works on Windows AND on macOS: electron-builder
|
||||
* fetches its own mac build of wine for the few Windows-only steps, so the laptop can produce the
|
||||
* exact installer the stage PC installs.
|
||||
*
|
||||
* npm run build:win
|
||||
*
|
||||
* Two things this wrapper exists to guarantee:
|
||||
*
|
||||
* - --x64. electron-builder otherwise targets the HOST architecture, so on an Apple Silicon Mac a
|
||||
* plain `electron-builder --win` quietly produces an arm64 build that the stage PC cannot run.
|
||||
* - a clean signing environment. There is no code-signing certificate. electron-builder aborts on
|
||||
* an EMPTY WIN_CSC_LINK ("Env WIN_CSC_LINK is not correct"), so those variables are removed
|
||||
* rather than blanked, and identity auto-discovery is turned off.
|
||||
*/
|
||||
const { spawnSync } = require('child_process');
|
||||
const path = require('path');
|
||||
|
||||
const env = { ...process.env, CSC_IDENTITY_AUTO_DISCOVERY: 'false' };
|
||||
for (const key of ['CSC_LINK', 'WIN_CSC_LINK', 'CSC_KEY_PASSWORD', 'WIN_CSC_KEY_PASSWORD', 'CSC_KEYCHAIN']) {
|
||||
delete env[key];
|
||||
}
|
||||
|
||||
const extra = process.argv.slice(2);
|
||||
const args = ['electron-builder', '--win', '--x64', ...extra];
|
||||
|
||||
console.log(`Building the Windows x64 installer on ${process.platform}…`);
|
||||
const r = spawnSync(process.platform === 'win32' ? 'npx.cmd' : 'npx', args, {
|
||||
cwd: path.join(__dirname, '..'),
|
||||
env,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32'
|
||||
});
|
||||
if (r.error) { console.error('Build failed to start:', r.error.message); process.exit(1); }
|
||||
process.exit(r.status === null ? 1 : r.status);
|
||||
+182
-29
@@ -1,19 +1,36 @@
|
||||
/*
|
||||
* publish-update.cjs — copy the freshly built installer to the church's update folder and write
|
||||
* the latest.json manifest the app reads. Run after a build:
|
||||
* publish-update.cjs — put the freshly built installer where the app's updater can find it, and
|
||||
* write the latest.json manifest it reads. Run after a build:
|
||||
*
|
||||
* npm run release (build + publish)
|
||||
* npm run publish-update (publish the newest existing build)
|
||||
* npm run publish-update -- "D:\Builds" (publish to a specific folder)
|
||||
* npm run publish-update -- "D:\Builds" "Notes about this version"
|
||||
* npm run publish-update -- "<dest>" (publish to a specific destination)
|
||||
* npm run publish-update -- "<dest>" "Notes about this version"
|
||||
*
|
||||
* Destination order: CLI arg -> BIBLEAPP_UPDATE_DIR env -> the folder configured in the app
|
||||
* (Notes/update-config.json) -> dist-installer/publish
|
||||
* A destination is either:
|
||||
*
|
||||
* a folder or network share \\SERVER\share\bible-app, D:\Builds -> the files are copied there
|
||||
* an http(s) URL http://gitea:3000/api/packages/... -> uploaded to Gitea
|
||||
*
|
||||
* The URL form targets a Gitea *generic package registry*, not a release: Gitea caps release
|
||||
* attachments (100 MB here, and .exe is not an allowed type) while the package registry takes the
|
||||
* ~750 MB installer and serves it to anonymous readers, which is what the updater needs — it sends
|
||||
* no credentials. Whatever destination you publish to is the exact string that goes in the app's
|
||||
* Settings > Updates > "Update folder or address".
|
||||
*
|
||||
* Destination order: CLI arg -> BIBLEAPP_UPDATE_DIR env -> package.json bibleApp.updateTarget
|
||||
* -> the folder configured in the app (Notes/update-config.json) -> dist-installer/publish
|
||||
*
|
||||
* Gitea auth: GITEA_TOKEN env, else the macOS keychain item the gitea tooling already uses
|
||||
* (service "gitea-mcp"). The token is never written to disk.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const crypto = require('crypto');
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
// Fingerprint a file so a truncated or interrupted copy can never be mistaken for a good build.
|
||||
function sha256(file) {
|
||||
@@ -32,6 +49,9 @@ const version = pkg.version;
|
||||
|
||||
function appNotesDir() {
|
||||
// Mirrors the app's user-content location so we can reuse the configured update folder.
|
||||
if (process.platform === 'darwin') {
|
||||
return path.join(os.homedir(), 'Library', 'Application Support', 'Bible App', 'user-content', 'Notes');
|
||||
}
|
||||
const base = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
|
||||
return path.join(base, 'Bible App', 'user-content', 'Notes');
|
||||
}
|
||||
@@ -42,7 +62,7 @@ function configuredDest() {
|
||||
if (fs.existsSync(p)) {
|
||||
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'));
|
||||
const s = String(cfg.source || '').trim();
|
||||
if (s && !/^https?:\/\//i.test(s)) return s; // http sources are uploaded by hand
|
||||
if (s) return s; // a URL is publishable now too (Gitea), not just a folder
|
||||
}
|
||||
} catch (_) {}
|
||||
return '';
|
||||
@@ -50,7 +70,10 @@ function configuredDest() {
|
||||
|
||||
const argDest = process.argv[2] && !process.argv[2].startsWith('--') ? process.argv[2] : '';
|
||||
const notes = process.argv[3] || '';
|
||||
const dest = argDest || process.env.BIBLEAPP_UPDATE_DIR || configuredDest() || path.join(ROOT, 'dist-installer', 'publish');
|
||||
// The church's canonical target, tracked in git so every clone publishes to the same place.
|
||||
const repoTarget = (pkg.bibleApp && typeof pkg.bibleApp.updateTarget === 'string') ? pkg.bibleApp.updateTarget.trim() : '';
|
||||
const dest = argDest || process.env.BIBLEAPP_UPDATE_DIR || repoTarget || configuredDest() || path.join(ROOT, 'dist-installer', 'publish');
|
||||
const isUrlDest = /^https?:\/\//i.test(dest);
|
||||
|
||||
const distDir = path.join(ROOT, 'dist-installer');
|
||||
if (!fs.existsSync(distDir)) {
|
||||
@@ -71,20 +94,162 @@ if (!installer) {
|
||||
console.warn(`! No installer named for v${version}; publishing newest: ${installer}`);
|
||||
}
|
||||
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
const srcPath = path.join(distDir, installer);
|
||||
const destPath = path.join(dest, installer);
|
||||
const sizeMB = (fs.statSync(srcPath).size / 1048576).toFixed(0);
|
||||
|
||||
/* ---- Gitea generic package registry -------------------------------------------------------- */
|
||||
|
||||
function giteaToken() {
|
||||
const env = (process.env.GITEA_TOKEN || process.env.GITEA_ACCESS_TOKEN || '').trim();
|
||||
if (env) return env;
|
||||
if (process.platform === 'darwin') {
|
||||
// Same keychain item the gitea MCP launcher reads, so there is one token to rotate, not two.
|
||||
try {
|
||||
const account = process.env.GITEA_KEYCHAIN_ACCOUNT || os.userInfo().username;
|
||||
return execFileSync('/usr/bin/security',
|
||||
['find-generic-password', '-s', 'gitea-mcp', '-a', account, '-w'],
|
||||
{ encoding: 'utf-8' }).trim();
|
||||
} catch (_) {}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// One request helper for PUT (upload) and DELETE. Streams the body so a 750 MB installer never
|
||||
// has to sit in memory, and reports progress because the upload is the slow part of a release.
|
||||
function giteaRequest(method, url, token, filePath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const u = new URL(url);
|
||||
const mod = u.protocol === 'https:' ? https : http;
|
||||
const headers = { Authorization: 'token ' + token };
|
||||
let total = 0;
|
||||
if (filePath) {
|
||||
total = fs.statSync(filePath).size;
|
||||
headers['Content-Length'] = total;
|
||||
headers['Content-Type'] = 'application/octet-stream';
|
||||
}
|
||||
const req = mod.request(url, { method, headers }, (res) => {
|
||||
let body = '';
|
||||
res.on('data', c => { body += c.toString('utf-8'); });
|
||||
res.on('end', () => resolve({ status: res.statusCode || 0, body }));
|
||||
});
|
||||
req.on('error', reject);
|
||||
if (filePath) {
|
||||
let sent = 0, lastPct = -1;
|
||||
const rs = fs.createReadStream(filePath);
|
||||
rs.on('data', (c) => {
|
||||
sent += c.length;
|
||||
const pct = total ? Math.floor((sent / total) * 100) : 0;
|
||||
if (pct !== lastPct && pct % 5 === 0) {
|
||||
lastPct = pct;
|
||||
process.stdout.write(`\r uploading… ${pct}% `);
|
||||
}
|
||||
});
|
||||
rs.on('error', reject);
|
||||
rs.pipe(req);
|
||||
} else {
|
||||
req.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Gitea refuses a PUT onto a filename that already exists in the package version, so each file is
|
||||
// removed first. A 404 here just means "nothing published yet", which is the normal first run.
|
||||
async function giteaPut(baseUrl, token, filename, filePath, label) {
|
||||
const target = baseUrl.replace(/\/+$/, '') + '/' + encodeURIComponent(filename);
|
||||
const del = await giteaRequest('DELETE', target, token, null);
|
||||
if (del.status !== 204 && del.status !== 404) {
|
||||
console.warn(`! could not clear the previous ${label} (HTTP ${del.status}) — continuing`);
|
||||
}
|
||||
const put = await giteaRequest('PUT', target, token, filePath);
|
||||
if (put.status !== 201 && put.status !== 200) {
|
||||
throw new Error(`upload of ${label} failed: HTTP ${put.status} ${put.body.slice(0, 200)}`);
|
||||
}
|
||||
process.stdout.write('\r' + ' '.repeat(28) + '\r');
|
||||
return target;
|
||||
}
|
||||
|
||||
// A new release uploads its installer under a NEW filename, so the superseded one is still sitting
|
||||
// in the package version taking ~750 MB. Clear the stale ones out once the new manifest is live —
|
||||
// never before, so a TV checking mid-publish always finds the installer its manifest names.
|
||||
// One previous build is kept as a hand-rollback copy; anything older goes.
|
||||
async function giteaPrune(baseUrl, token, keepFile, keepPrevious = 1) {
|
||||
const u = new URL(baseUrl.replace(/\/+$/, ''));
|
||||
// .../api/packages/<owner>/generic/<name>/<version> -> .../api/v1/packages/<owner>/generic/<name>/<version>/files
|
||||
const parts = u.pathname.split('/').filter(Boolean); // api, packages, owner, generic, name, version
|
||||
const i = parts.indexOf('packages');
|
||||
if (i === -1 || parts.length < i + 5) return;
|
||||
const [owner, type, name, ver] = parts.slice(i + 1, i + 5);
|
||||
const listUrl = `${u.origin}/api/v1/packages/${owner}/${type}/${name}/${ver}/files`;
|
||||
const res = await giteaRequest('GET', listUrl, token, null);
|
||||
if (res.status !== 200) return; // listing is a nicety, not a gate
|
||||
let files;
|
||||
try { files = JSON.parse(res.body); } catch (_) { return; }
|
||||
if (!Array.isArray(files)) return;
|
||||
const stale = files
|
||||
.filter(f => f && typeof f.name === 'string' && f.name.toLowerCase().endsWith('.exe') && f.name !== keepFile)
|
||||
.sort((a, b) => new Date(b.created_at || 0) - new Date(a.created_at || 0))
|
||||
.slice(keepPrevious);
|
||||
for (const f of stale) {
|
||||
const del = await giteaRequest('DELETE', baseUrl.replace(/\/+$/, '') + '/' + encodeURIComponent(f.name), token, null);
|
||||
if (del.status === 204) console.log(` removed old build: ${f.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------------------------- */
|
||||
|
||||
(async () => {
|
||||
console.log(`Publishing v${version}`);
|
||||
console.log(` installer : ${installer} (${sizeMB} MB)`);
|
||||
console.log(` to : ${dest}`);
|
||||
|
||||
const installerSize = fs.statSync(srcPath).size;
|
||||
console.log(' hashing installer (integrity check)…');
|
||||
const installerHash = await sha256(srcPath);
|
||||
|
||||
const manifest = {
|
||||
version,
|
||||
file: installer,
|
||||
size: installerSize,
|
||||
sha256: installerHash,
|
||||
notes: notes || `Bible App ${version}`,
|
||||
date: new Date().toISOString().slice(0, 10)
|
||||
};
|
||||
|
||||
if (isUrlDest) {
|
||||
const token = giteaToken();
|
||||
if (!token) {
|
||||
console.error('No Gitea token. Set GITEA_TOKEN, or store one in the macOS keychain:');
|
||||
console.error(' security add-generic-password -s gitea-mcp -a "$USER" -w <token>');
|
||||
process.exit(1);
|
||||
}
|
||||
const manifestPath = path.join(os.tmpdir(), 'bibleapp-latest.json');
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
|
||||
|
||||
// The installer goes up first: until latest.json names it, no app can act on a half-published
|
||||
// release. Publishing the manifest last is what makes the switch atomic from the TV's point of view.
|
||||
console.log(` uploading installer (${sizeMB} MB)…`);
|
||||
await giteaPut(dest, token, installer, srcPath, 'installer');
|
||||
console.log(' uploading latest.json…');
|
||||
await giteaPut(dest, token, 'latest.json', manifestPath, 'manifest');
|
||||
try { fs.unlinkSync(manifestPath); } catch (_) {}
|
||||
try { await giteaPrune(dest, token, installer); } catch (e) { console.warn('! could not prune old builds:', e.message); }
|
||||
|
||||
console.log(` sha256 : ${installerHash}`);
|
||||
console.log('\nDone. The app will offer this version from Settings > Updates.');
|
||||
console.log(`Update source to set on each machine:\n ${dest}`);
|
||||
return;
|
||||
}
|
||||
|
||||
/* ---- Folder / network-share destination (unchanged behaviour) ------------------------------- */
|
||||
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
const destPath = path.join(dest, installer);
|
||||
fs.copyFileSync(srcPath, destPath);
|
||||
|
||||
/*
|
||||
* The AI model does NOT need to go in the update folder any more: a new machine gets it from the
|
||||
* one-file "Bible App Full Setup" bundle, and machines that already have it keep it (it lives in
|
||||
* ProgramData/user-data, which no installer touches). So updates stay ~300 MB instead of ~2.2 GB.
|
||||
* ProgramData/user-data, which no installer touches). So updates stay ~750 MB instead of ~2.7 GB.
|
||||
*
|
||||
* We still refresh a model.gguf that is ALREADY published, so an existing Settings > Local AI
|
||||
* install path keeps working; pass --with-model to publish it for the first time.
|
||||
@@ -113,30 +278,18 @@ try {
|
||||
console.warn('! Could not publish the AI model:', e.message);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const installerSize = fs.statSync(destPath).size;
|
||||
const installerHash = await sha256(destPath);
|
||||
let modelSize, modelHash;
|
||||
if (modelPublished) {
|
||||
try {
|
||||
const md = path.join(dest, 'model.gguf');
|
||||
if (modelPublished && fs.existsSync(md)) {
|
||||
modelSize = fs.statSync(md).size;
|
||||
if (fs.existsSync(md)) {
|
||||
manifest.model = 'model.gguf';
|
||||
manifest.modelSize = fs.statSync(md).size;
|
||||
console.log(' hashing model (integrity check)…');
|
||||
modelHash = await sha256(md);
|
||||
manifest.modelSha256 = await sha256(md);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
version,
|
||||
file: installer,
|
||||
size: installerSize,
|
||||
sha256: installerHash,
|
||||
notes: notes || `Bible App ${version}`,
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
model: modelPublished ? 'model.gguf' : undefined,
|
||||
modelSize,
|
||||
modelSha256: modelHash
|
||||
};
|
||||
fs.writeFileSync(path.join(dest, 'latest.json'), JSON.stringify(manifest, null, 2) + '\n');
|
||||
// Companion file so a person can verify a hand-copied installer:
|
||||
// Get-FileHash "Bible App Setup x.y.z.exe" -Algorithm SHA256
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* release.cjs — one command to ship a new version from the laptop:
|
||||
*
|
||||
* npm run release -- "Notes the operator will see in Settings > Updates"
|
||||
* npm run release -- --no-bump "Re-publish the current version"
|
||||
* npm run release -- --dest "\\SERVER\share\bible-app" "Notes"
|
||||
*
|
||||
* It bumps the patch version, builds the Windows x64 installer, and publishes it to the update
|
||||
* target. The version bump matters: the TV's updater compares versions numerically and only offers
|
||||
* a HIGHER one, so re-publishing the same version is invisible to it.
|
||||
*
|
||||
* Default destination is package.json > bibleApp.updateTarget (the church's Gitea package
|
||||
* registry). Pass --dest, or set BIBLEAPP_UPDATE_DIR, to publish somewhere else.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const PKG = path.join(ROOT, 'package.json');
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const noBump = argv.includes('--no-bump');
|
||||
let dest = '';
|
||||
const destAt = argv.indexOf('--dest');
|
||||
if (destAt !== -1 && argv[destAt + 1]) dest = argv[destAt + 1];
|
||||
// Whatever is left over, ignoring the flags and their values, is the release note.
|
||||
const notes = argv.filter((a, i) =>
|
||||
!a.startsWith('--') && !(destAt !== -1 && i === destAt + 1)
|
||||
).join(' ').trim();
|
||||
|
||||
function run(label, cmd, args) {
|
||||
console.log(`\n=== ${label} ===`);
|
||||
const r = spawnSync(cmd, args, { cwd: ROOT, stdio: 'inherit' });
|
||||
if (r.error) { console.error(`${label} failed to start:`, r.error.message); process.exit(1); }
|
||||
if (r.status !== 0) { console.error(`${label} failed (exit ${r.status}).`); process.exit(r.status || 1); }
|
||||
}
|
||||
|
||||
const pkg = JSON.parse(fs.readFileSync(PKG, 'utf-8'));
|
||||
|
||||
if (!noBump) {
|
||||
const parts = String(pkg.version).split('.').map(n => parseInt(n, 10) || 0);
|
||||
while (parts.length < 3) parts.push(0);
|
||||
parts[2] += 1;
|
||||
const next = parts.join('.');
|
||||
// Rewrite only the version line, so the file keeps its own formatting and key order.
|
||||
const raw = fs.readFileSync(PKG, 'utf-8');
|
||||
fs.writeFileSync(PKG, raw.replace(
|
||||
/^(\s*"version"\s*:\s*")[^"]+(",?)$/m,
|
||||
(_m, a, b) => a + next + b
|
||||
));
|
||||
console.log(`Version ${pkg.version} -> ${next}`);
|
||||
pkg.version = next;
|
||||
} else {
|
||||
console.log(`Version ${pkg.version} (not bumped)`);
|
||||
}
|
||||
|
||||
run('Build', process.execPath, [path.join(__dirname, 'build-win.cjs')]);
|
||||
|
||||
const target = dest || process.env.BIBLEAPP_UPDATE_DIR || (pkg.bibleApp && pkg.bibleApp.updateTarget) || '';
|
||||
const publishArgs = [path.join(__dirname, 'publish-update.cjs')];
|
||||
if (target) publishArgs.push(target, notes || `Bible App ${pkg.version}`);
|
||||
else if (notes) publishArgs.push('', notes);
|
||||
run('Publish', process.execPath, publishArgs);
|
||||
|
||||
console.log(`\nReleased ${pkg.version}.`);
|
||||
console.log('Commit the version bump so git matches what the TVs are running:');
|
||||
console.log(` git commit -am "Release ${pkg.version}" && git push`);
|
||||
Reference in New Issue
Block a user