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:
2026-09-24 13:17:55 -04:00
co-authored by Claude Opus 5
parent 0db5e9b07d
commit 838a87dbf3
6 changed files with 422 additions and 100 deletions
+35
View File
@@ -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);
+187 -34
View File
@@ -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;
try {
const md = path.join(dest, 'model.gguf');
if (modelPublished && fs.existsSync(md)) {
modelSize = fs.statSync(md).size;
console.log(' hashing model (integrity check)…');
modelHash = await sha256(md);
}
} catch (_) {}
if (modelPublished) {
try {
const md = path.join(dest, 'model.gguf');
if (fs.existsSync(md)) {
manifest.model = 'model.gguf';
manifest.modelSize = fs.statSync(md).size;
console.log(' hashing model (integrity check)…');
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
+68
View File
@@ -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`);