waybetter release: builds of the committed state on a Mac over SSH and in WSL

release macos --remote and release linux --wsl export HEAD plus each submodule at its recorded commit (git archive, never the working tree, nothing fetched from origin), build it path-free through build-env with unsigned bundles, bring the artifacts back and scan them here. release linux --appimage/--deb signs the AppImage (the Linux update payload) on the release machine and adds linux-x86_64 to latest.json. The remote script is low priority, never installs, quits or replaces an app on the Mac. Config: prepareCommand, exportExclude.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
CRTkafa 2026-09-30 23:24:04 +07:00
parent f8144a3bff
commit 1b0a489b36
4 changed files with 477 additions and 18 deletions

View file

@ -1,6 +1,6 @@
#!/usr/bin/env node #!/usr/bin/env node
// The `waybetter` command: `waybetter store build|sign|verify` (cli/store.mjs), // The `waybetter` command: `waybetter store build|sign|verify` (cli/store.mjs),
// `waybetter release windows|macos` (cli/release.mjs), `waybetter i18n check|extract` // `waybetter release windows|macos|linux` (cli/release.mjs), `waybetter i18n check|extract`
// (cli/i18n.mjs), `waybetter privacy-scan` (cli/privacy.mjs) and `waybetter build-env` // (cli/i18n.mjs), `waybetter privacy-scan` (cli/privacy.mjs) and `waybetter build-env`
// (cli/buildenv.mjs), `waybetter dev` (cli/dev.mjs: watchers + tauri dev), `waybetter strings check` // (cli/buildenv.mjs), `waybetter dev` (cli/dev.mjs: watchers + tauri dev), `waybetter strings check`
// (cli/strings.mjs). // (cli/strings.mjs).

View file

@ -14,6 +14,8 @@
// "outDir": "releases", // releases/upload (files), releases/native/latest.json // "outDir": "releases", // releases/upload (files), releases/native/latest.json
// "manifestValidator": { "module": "packages/contracts/dist/index.js", "export": "NativeRelease", // "manifestValidator": { "module": "packages/contracts/dist/index.js", "export": "NativeRelease",
// "hint": "build packages/contracts first" }, // optional, zod-like .parse // "hint": "build packages/contracts first" }, // optional, zod-like .parse
// "prepareCommand": "bun install --frozen-lockfile", // remote/WSL builds: runs first in the unpacked sources
// "exportExclude": ["verification"], // remote/WSL builds: app-repo paths left out of the upload
// "forbiddenTerms": "~/.better-twttr-keys/forbidden-names.txt", // optional, outside the repo // "forbiddenTerms": "~/.better-twttr-keys/forbidden-names.txt", // optional, outside the repo
// "privacyAllowlist": "privacy-allowlist.txt" // optional, reviewed false positives // "privacyAllowlist": "privacy-allowlist.txt" // optional, reviewed false positives
// } // }
@ -34,6 +36,7 @@ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
import { pathToFileURL } from 'node:url'; import { pathToFileURL } from 'node:url';
import { pathFreeBuildEnv } from './buildenv.mjs'; import { pathFreeBuildEnv } from './buildenv.mjs';
import { assertClean } from './privacy.mjs'; import { assertClean } from './privacy.mjs';
import { buildLinuxWsl, buildMacRemote, scanBuilt } from './remote.mjs';
export const CONFIG_FILE = 'waybetter.release.json'; export const CONFIG_FILE = 'waybetter.release.json';
@ -44,6 +47,19 @@ export const RELEASE_USAGE = `Usage:
waybetter release macos --dmg <file.dmg> --updater <file.app.tar.gz> [--arch universal] [--config <file>] waybetter release macos --dmg <file.dmg> --updater <file.app.tar.gz> [--arch universal] [--config <file>]
Signs the updater bundle of a universal macOS build here, copies both into <outDir>/upload and Signs the updater bundle of a universal macOS build here, copies both into <outDir>/upload and
adds darwin-aarch64 and darwin-x86_64 to latest.json (run the Windows step first). adds darwin-aarch64 and darwin-x86_64 to latest.json (run the Windows step first).
waybetter release macos --remote <user@host> [--ssh-key <file>] [--remote-dir <dir>] [--build-only]
Builds the COMMITTED state (HEAD and the submodules at their recorded commits, exported with
git archive, never the working tree) on a Mac over SSH, universal and ad-hoc signed, without
a build path in it; brings the .dmg and .app.tar.gz to <outDir>/build/macos, scans them here,
then (unless --build-only) continues as above. The built .app stays in the remote work
folder (<remote-dir>/out); nothing is installed or replaced on the Mac. Env: WAYBETTER_MAC_SSH (user@host), WAYBETTER_MAC_SSH_KEY.
waybetter release linux --wsl [<distro>] [--remote-dir <dir>] [--build-only]
Builds the committed state in WSL (default distro Ubuntu-22.04, or WAYBETTER_WSL_DISTRO): the
AppImage and the .deb in <outDir>/build/linux, scanned here; then (unless --build-only) the
same as \`release linux --appimage\`.
waybetter release linux --appimage <file.AppImage> --deb <file.deb> [--config <file>]
Signs the AppImage (the Linux update payload) here, copies both into <outDir>/upload and adds
linux-x86_64 to latest.json (run the Windows step first).
Config: ${CONFIG_FILE} in the app's root (product, artifact, desktopDir, keysDir, keysDirEnv, Config: ${CONFIG_FILE} in the app's root (product, artifact, desktopDir, keysDir, keysDirEnv,
cdnBase, apiBase, outDir, optional manifestValidator, forbiddenTerms, privacyAllowlist). Env: cdnBase, apiBase, outDir, optional manifestValidator, forbiddenTerms, privacyAllowlist). Env:
@ -104,6 +120,8 @@ export function normalizeReleaseConfig(raw, root) {
apiBase: url('apiBase'), apiBase: url('apiBase'),
outDir: at(text('outDir', 'releases')), outDir: at(text('outDir', 'releases')),
buildCommand: text('buildCommand', 'bun run tauri build'), buildCommand: text('buildCommand', 'bun run tauri build'),
prepareCommand: text('prepareCommand', 'bun install --frozen-lockfile'),
exportExclude: Array.isArray(raw.exportExclude) ? raw.exportExclude.map(String) : [],
manifestValidator: validator && { module: at(validator.module), export: validator.export, hint: validator.hint }, manifestValidator: validator && { module: at(validator.module), export: validator.export, hint: validator.hint },
forbiddenTerms: raw.forbiddenTerms === undefined ? undefined : at(home(text('forbiddenTerms'))), forbiddenTerms: raw.forbiddenTerms === undefined ? undefined : at(home(text('forbiddenTerms'))),
privacyAllowlist: raw.privacyAllowlist === undefined ? undefined : at(text('privacyAllowlist')), privacyAllowlist: raw.privacyAllowlist === undefined ? undefined : at(text('privacyAllowlist')),
@ -249,11 +267,39 @@ Nothing was uploaded. Run these steps yourself, in this order:
return { version, manifestPath, files: [fileName, `${fileName}.sig`] }; return { version, manifestPath, files: [fileName, `${fileName}.sig`] };
} }
/** Signs a copy in the upload folder (the signature's trusted comment names the file and binds the version). */
function signFile(config, file, version, env) {
const key = keyPath(config, env);
if (!existsSync(key)) fail(`signing key not found at ${key}`);
const exe = process.platform === 'win32' ? 'tauri.exe' : 'tauri';
const tauri = [join(config.desktopDir, 'node_modules', '.bin', exe), join(config.root, 'node_modules', '.bin', exe)].find(existsSync);
if (!tauri) fail('Tauri CLI not installed (bun install)');
const signed = spawnSync(tauri, ['signer', 'sign', '-f', key, '-p', '', '--app-version', version, file], {
stdio: ['ignore', 'ignore', 'inherit'], // stdout would echo the signature; the .sig file holds it
});
if (signed.status !== 0) fail(`tauri signer sign failed (exit ${signed.status})`);
const sigPath = `${file}.sig`;
if (!existsSync(sigPath)) fail(`signer wrote no ${sigPath}`);
return sigPath;
}
/** `waybetter release macos`: adds a universal macOS build (built unsigned on the Mac) to the release. */ /** `waybetter release macos`: adds a universal macOS build (built unsigned on the Mac) to the release. */
export async function releaseMacos(config, args, { env = process.env, log = console.log } = {}) { export async function releaseMacos(config, args, { env = process.env, log = console.log } = {}) {
const version = JSON.parse(readFileSync(join(config.root, 'package.json'), 'utf8')).version; const version = JSON.parse(readFileSync(join(config.root, 'package.json'), 'utf8')).version;
const dmg = option(args, '--dmg'); let dmg = option(args, '--dmg');
const updater = option(args, '--updater'); let updater = option(args, '--updater');
const target = option(args, '--remote') ?? (dmg ? undefined : env.WAYBETTER_MAC_SSH);
if (target) {
const built = buildMacRemote({ config, target, key: option(args, '--ssh-key') ?? env.WAYBETTER_MAC_SSH_KEY, remoteDir: option(args, '--remote-dir'), env, log });
scanBuilt(config, [built.dmg, built.updater], { env, log });
if (built.version !== version) fail(`the committed version is ${built.version} but the working tree says ${version}: commit or sync first`);
if (args.includes('--build-only')) {
log(`release-macos: built ${built.sha.slice(0, 12)} in ${relative(config.root, built.dir).replaceAll('\\', '/')} (scanned, not signed)`);
return { version, dir: built.dir, files: [built.dmg, built.updater] };
}
dmg = built.dmg;
updater = built.updater;
}
const arch = option(args, '--arch') ?? 'universal'; const arch = option(args, '--arch') ?? 'universal';
if (!dmg || !updater) fail('usage: --dmg <file.dmg> --updater <file.app.tar.gz> [--arch universal]'); if (!dmg || !updater) fail('usage: --dmg <file.dmg> --updater <file.app.tar.gz> [--arch universal]');
if (arch !== 'universal') fail('only universal builds are supported (one file serves both darwin arches)'); if (arch !== 'universal') fail('only universal builds are supported (one file serves both darwin arches)');
@ -268,18 +314,7 @@ export async function releaseMacos(config, args, { env = process.env, log = cons
copyFileSync(dmg, join(uploadDir, dmgName)); copyFileSync(dmg, join(uploadDir, dmgName));
copyFileSync(updater, join(uploadDir, updaterName)); copyFileSync(updater, join(uploadDir, updaterName));
// Sign the copy (the signature's trusted comment names this file and binds the version). const sigPath = signFile(config, join(uploadDir, updaterName), version, env);
const key = keyPath(config, env);
if (!existsSync(key)) fail(`signing key not found at ${key}`);
const exe = process.platform === 'win32' ? 'tauri.exe' : 'tauri';
const tauri = [join(config.desktopDir, 'node_modules', '.bin', exe), join(config.root, 'node_modules', '.bin', exe)].find(existsSync);
if (!tauri) fail('Tauri CLI not installed (bun install)');
const signed = spawnSync(tauri, ['signer', 'sign', '-f', key, '-p', '', '--app-version', version, join(uploadDir, updaterName)], {
stdio: ['ignore', 'ignore', 'inherit'], // stdout would echo the signature; the .sig file holds it
});
if (signed.status !== 0) fail(`tauri signer sign failed (exit ${signed.status})`);
const sigPath = join(uploadDir, `${updaterName}.sig`);
if (!existsSync(sigPath)) fail(`signer wrote no ${sigPath}`);
const manifestPath = join(config.outDir, 'native', 'latest.json'); const manifestPath = join(config.outDir, 'native', 'latest.json');
if (!existsSync(manifestPath)) fail(`no ${relative(config.root, manifestPath)}: run \`waybetter release windows\` first`); if (!existsSync(manifestPath)) fail(`no ${relative(config.root, manifestPath)}: run \`waybetter release windows\` first`);
@ -301,14 +336,66 @@ then publish latest.json the same way (tmp file, then mv), and verify
return { version, manifestPath, files: [dmgName, updaterName, `${updaterName}.sig`] }; return { version, manifestPath, files: [dmgName, updaterName, `${updaterName}.sig`] };
} }
/** `waybetter release <windows|macos> ...` from the command line (or an app's wrapper script). */ /** `waybetter release linux`: adds the AppImage (signed here: it is the update payload) and the .deb. */
export async function releaseLinux(config, args, { env = process.env, log = console.log } = {}) {
const version = JSON.parse(readFileSync(join(config.root, 'package.json'), 'utf8')).version;
let appimage = option(args, '--appimage');
let deb = option(args, '--deb');
if (args.includes('--wsl')) {
const next = args[args.indexOf('--wsl') + 1];
const distro = next && !next.startsWith('--') ? next : env.WAYBETTER_WSL_DISTRO;
const built = buildLinuxWsl({ config, distro, remoteDir: option(args, '--remote-dir'), env, log });
scanBuilt(config, [built.appimage, built.deb], { env, log });
if (built.version !== version) fail(`the committed version is ${built.version} but the working tree says ${version}: commit or sync first`);
if (args.includes('--build-only')) {
log(`release-linux: built ${built.sha.slice(0, 12)} in ${relative(config.root, built.dir).replaceAll('\\', '/')} (scanned, not signed)`);
return { version, dir: built.dir, files: [built.appimage, built.deb] };
}
appimage = built.appimage;
deb = built.deb;
}
if (!appimage || !deb) fail('usage: --appimage <file.AppImage> --deb <file.deb> (or --wsl)');
for (const file of [appimage, deb]) if (!existsSync(file)) fail(`not found: ${file}`);
if (!appimage.endsWith('.AppImage')) fail('--appimage must be the .AppImage Tauri wrote');
if (!deb.endsWith('.deb')) fail('--deb must be the .deb Tauri wrote');
gate(config, [resolve(appimage), resolve(deb)], env, log);
const uploadDir = join(config.outDir, 'upload');
mkdirSync(uploadDir, { recursive: true });
const appimageName = `${config.artifact}_${version}_amd64.AppImage`;
const debName = `${config.artifact}_${version}_amd64.deb`;
copyFileSync(appimage, join(uploadDir, appimageName));
copyFileSync(deb, join(uploadDir, debName));
const sigPath = signFile(config, join(uploadDir, appimageName), version, env);
const manifestPath = join(config.outDir, 'native', 'latest.json');
if (!existsSync(manifestPath)) fail(`no ${relative(config.root, manifestPath)}: run \`waybetter release windows\` first`);
const current = JSON.parse(readFileSync(manifestPath, 'utf8'));
if (current.version !== version) fail(`latest.json is ${current.version}, this build is ${version}`);
const cdnBase = (env.CDN_BASE ?? config.cdnBase).replace(/\/+$/, '');
const entry = { url: `${cdnBase}/${appimageName}`, signature: readFileSync(sigPath, 'utf8').trim() };
const manifest = await validate(config, { ...current, platforms: { ...current.platforms, 'linux-x86_64': entry } });
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
log(`release-linux: ${appimageName} (+ .sig), ${debName} in ${relative(config.root, uploadDir).replaceAll('\\', '/')}; latest.json now has ${Object.keys(manifest.platforms).join(', ')}`);
log(`
Nothing was uploaded. Upload the AppImage, its .sig and the .deb to the CDN next to the Windows
installer (same target and folder as \`waybetter release windows\` prints), check
curl -sSfI ${cdnBase}/${appimageName}
then publish latest.json the same way (tmp file, then mv), and verify
curl -sS ${config.apiBase}/v1/updates/linux/x86_64/0.0.0
`);
return { version, manifestPath, files: [appimageName, `${appimageName}.sig`, debName] };
}
/** `waybetter release <windows|macos|linux> ...` from the command line (or an app's wrapper script). */
export async function releaseCommand(args, { cwd = process.cwd(), env = process.env, log = console.log } = {}) { export async function releaseCommand(args, { cwd = process.cwd(), env = process.env, log = console.log } = {}) {
const [platform, ...rest] = args; const [platform, ...rest] = args;
if (platform !== 'windows' && platform !== 'macos') { if (platform !== 'windows' && platform !== 'macos' && platform !== 'linux') {
log(RELEASE_USAGE); log(RELEASE_USAGE);
if (platform && platform !== '--help' && platform !== '-h') fail(`unknown release target ${platform}`); if (platform && platform !== '--help' && platform !== '-h') fail(`unknown release target ${platform}`);
return undefined; return undefined;
} }
const config = loadReleaseConfig(cwd, option(rest, '--config')); const config = loadReleaseConfig(cwd, option(rest, '--config'));
return platform === 'windows' ? releaseWindows(config, rest, { env, log }) : releaseMacos(config, rest, { env, log }); const release = { windows: releaseWindows, macos: releaseMacos, linux: releaseLinux }[platform];
return release(config, rest, { env, log });
} }

View file

@ -0,0 +1,256 @@
// Remote and WSL builds for `waybetter release macos|linux`: the committed state of an app (its
// git HEAD plus every submodule at the commit the app records) is exported, sent to the machine
// that builds it, built there through `waybetter build-env` (no build path in the binary) and the
// artifacts come back to be scanned (`waybetter privacy-scan`) and, later, signed on this machine.
// Nothing here touches the working tree the developer has open, and no key ever leaves this
// machine: the remote build is unsigned (updater artifacts off), signing is `waybetter release`.
//
// The export is `git archive` of the app at HEAD plus `git archive` of each submodule at the commit
// HEAD points to, read from the local submodule repository. A submodule's newest commits do not
// have to be on any remote, so nothing is ever fetched from origin.
//
// Where it builds is never written down here: an SSH target (user@host, WAYBETTER_MAC_SSH or
// --remote), its key (WAYBETTER_MAC_SSH_KEY or --ssh-key) and a WSL distro (--wsl,
// WAYBETTER_WSL_DISTRO) come from the caller. The remote scripts use a login shell (macOS: bun,
// cargo and node exist only there) and a clean PATH (WSL: no Windows programs shadowing the
// Linux ones).
import { spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { basename, join, relative, resolve } from 'node:path';
import { assertClean } from './privacy.mjs';
class RemoteError extends Error {}
const fail = message => { throw new RemoteError(message); };
/** Single-quotes a value for a POSIX shell. */
export const shq = value => `'${String(value).replaceAll("'", `'\\''`)}'`;
function run(command, args, options = {}) {
const result = spawnSync(command, args, { encoding: 'utf8', windowsHide: true, maxBuffer: 1 << 28, ...options });
if (result.error) fail(`${command}: ${result.error.message}`);
return result;
}
function git(root, args, options) {
const result = run('git', ['-C', root, ...args], options);
if (result.status !== 0) fail(`git ${args[0]} failed: ${(result.stderr || '').trim().split('\n')[0]}`);
return result.stdout ?? '';
}
/** A file of the committed state (`git show HEAD:<path>`), for the version and product name. */
export const committedFile = (root, path) => git(root, ['show', `HEAD:${path}`]);
/** The submodules HEAD records: [{ path, sha }]. */
export function committedSubmodules(root) {
return git(root, ['ls-tree', '-r', 'HEAD'])
.split('\n')
.map(line => /^160000 commit ([0-9a-f]{40})\t(.+)$/.exec(line))
.filter(Boolean)
.map(([, sha, path]) => ({ path, sha }));
}
/**
* Exports the committed state of `root` (HEAD, and each submodule at its recorded commit) to a
* `.tar.gz` at `file`. Returns `{ sha, submodules, file, bytes, dirty }`; `dirty` lists tracked
* files changed in the working tree, which the export ignores by design.
*/
export function exportCommitted({ root, file, exclude = [], log = () => {} }) {
const sha = git(root, ['rev-parse', 'HEAD']).trim();
const submodules = committedSubmodules(root);
const dirty = git(root, ['status', '--porcelain', '--untracked-files=no']).split('\n').filter(Boolean).length;
const staging = mkdtempSync(join(tmpdir(), 'wb-export-'));
try {
const parts = [{ repo: root, ref: 'HEAD', prefix: '' }, ...submodules.map(({ path, sha: ref }) => ({ repo: join(root, path), ref, prefix: `${path}/`, path }))];
for (const [index, part] of parts.entries()) {
if (part.path && !existsSync(join(part.repo, '.git'))) fail(`submodule ${part.path} is not checked out here (needed for commit ${part.ref.slice(0, 12)})`);
const tarName = `part-${index}.tar`;
// `exclude` (paths of the app repo the build never reads, e.g. screenshots) keeps the upload small.
const skip = part.path ? [] : ['--', '.', ...exclude.map(path => `:(exclude)${path}`)];
const args = ['archive', '--format=tar', ...(part.prefix ? [`--prefix=${part.prefix}`] : []), '-o', join(staging, tarName), part.ref, ...skip];
const archived = run('git', ['-C', part.repo, ...args]);
if (archived.status !== 0) fail(`git archive ${part.path ?? 'app'} at ${part.ref.slice(0, 12)} failed: ${(archived.stderr || '').trim().split('\n')[0]} (is that commit in the local repository?)`);
// Relative names and cwd: a drive letter in an argument reads as a remote host to GNU tar.
mkdirSync(join(staging, 'tree'), { recursive: true });
const unpacked = run('tar', ['-xf', tarName, '-C', 'tree'], { cwd: staging });
if (unpacked.status !== 0) fail(`tar -x failed: ${(unpacked.stderr || '').trim().split('\n')[0]}`);
rmSync(join(staging, tarName));
}
mkdirSync(resolve(file, '..'), { recursive: true });
const outName = 'export.tgz';
const packed = run('tar', ['-czf', outName, '-C', 'tree', '.'], { cwd: staging });
if (packed.status !== 0) fail(`tar -c failed: ${(packed.stderr || '').trim().split('\n')[0]}`);
writeFileSync(file, readFileSync(join(staging, outName)));
} finally {
rmSync(staging, { recursive: true, force: true });
}
const bytes = statSync(file).size;
log(`export: ${sha.slice(0, 12)}${submodules.map(s => ` + ${s.path}@${s.sha.slice(0, 12)}`).join('')} -> ${(bytes / 1048576).toFixed(1)} MB${dirty ? ` (${dirty} tracked file(s) changed in the working tree are NOT included)` : ''}`);
return { sha, submodules, file, bytes, dirty };
}
/** Remote work folders are relative to the login home. */
const workDirOf = (dir, artifact) => (dir ?? `waybetter-builds/${artifact}`).replace(/^~\//, '').replace(/^\/+/, '') || `waybetter-builds/${artifact}`;
const sha256Line = 'if command -v shasum >/dev/null 2>&1; then shasum -a 256 "$@"; else sha256sum "$@"; fi';
/**
* The macOS build script (bash, run in a login shell on the Mac): unpack, install, universal
* ad-hoc-signed .app and .dmg through build-env, the updater bundle as a tar.gz of the .app; the
* dmg and the tar.gz under space-free names, and the .app itself, in `out/`. It never installs,
* quits or replaces an app on the Mac: whoever runs the app there swaps it in.
*/
export function macBuildScript({ workDir, sha, artifact, productName, version, desktopDir, prepareCommand }) {
const app = `${productName}.app`;
return `#!/bin/bash
set -eu
# Low priority: the Mac may be recording or running an app while this compiles.
renice -n 10 $$ >/dev/null 2>&1 || true
# rustup first: Homebrew's Rust has no x86_64 standard library, which a universal build needs.
export PATH="$HOME/.cargo/bin:$PATH"
WORK="$HOME/${workDir}"
SRC="$WORK/src"
OUT="$WORK/out"
cd "$WORK"
rm -rf "$SRC" "$OUT"
mkdir -p "$SRC" "$OUT"
tar -xzf "$WORK/source-${sha.slice(0, 12)}.tgz" -C "$SRC"
cd "$SRC"
rustup target add aarch64-apple-darwin x86_64-apple-darwin
${prepareCommand}
cd ${shq(desktopDir)}
# Ad-hoc signature (no Developer ID yet: Gatekeeper's "Open Anyway" path, not "is damaged"), no
# updater bundle from Tauri (it needs the signing key, which never leaves the release machine).
printf '%s' '{"bundle":{"createUpdaterArtifacts":false,"macOS":{"signingIdentity":"-"}}}' > "$WORK/override.json"
export CARGO_TARGET_DIR="$WORK/target"
bun "$SRC/waybetter/packages/waybetter/bin/waybetter.mjs" build-env -- bunx tauri build --target universal-apple-darwin --bundles app,dmg --ci --config "$WORK/override.json"
BUNDLE="$CARGO_TARGET_DIR/universal-apple-darwin/release/bundle"
DMG="$(ls "$BUNDLE"/dmg/*_${version}_universal.dmg | head -1)"
cp "$DMG" "$OUT/${artifact}_${version}_universal.dmg"
COPYFILE_DISABLE=1 tar -czf "$OUT/${artifact}_${version}_universal.app.tar.gz" -C "$BUNDLE/macos" ${shq(app)}
ditto "$BUNDLE/macos/"${shq(app)} "$OUT/"${shq(app)}
cd "$OUT"
set -- *.dmg *.tar.gz
${sha256Line} > SHA256SUMS
ls -l "$OUT"
echo "REMOTE_BUILD_OK ${sha.slice(0, 12)}"
`;
}
/**
* The Linux build script (bash, run in WSL or on any Ubuntu 22.04): clean PATH, unpack, install,
* AppImage and .deb through build-env with Tauri's updater bundle off (on Linux the signed
* AppImage itself is the update payload), copied to `out/` under space-free names.
*/
export function linuxBuildScript({ workDir, sha, artifact, version, desktopDir, prepareCommand, tarball, outDir }) {
return `#!/bin/bash
set -eu
# A clean PATH: under WSL the Windows programs (a Windows bun, node, cargo) would shadow these.
export PATH="$HOME/.bun/bin:$HOME/.cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
export APPIMAGE_EXTRACT_AND_RUN=1
WORK="$HOME/${workDir}"
SRC="$WORK/src"
cd "$HOME"
mkdir -p "$WORK"
rm -rf "$SRC"
mkdir -p "$SRC"
tar -xzf "$(wslpath -u ${shq(tarball)} 2>/dev/null || echo ${shq(tarball)})" -C "$SRC"
cd "$SRC"
${prepareCommand}
cd ${shq(desktopDir)}
printf '%s' '{"bundle":{"createUpdaterArtifacts":false}}' > "$WORK/override.json"
export CARGO_TARGET_DIR="$WORK/target"
bun "$SRC/waybetter/packages/waybetter/bin/waybetter.mjs" build-env -- bunx tauri build --bundles appimage,deb --ci --config "$WORK/override.json"
BUNDLE="$CARGO_TARGET_DIR/release/bundle"
OUTDIR="$(wslpath -u ${shq(outDir)} 2>/dev/null || echo ${shq(outDir)})"
mkdir -p "$OUTDIR"
cp "$(ls "$BUNDLE"/appimage/*_${version}_amd64.AppImage | head -1)" "$OUTDIR/${artifact}_${version}_amd64.AppImage"
cp "$(ls "$BUNDLE"/deb/*_${version}_amd64.deb | head -1)" "$OUTDIR/${artifact}_${version}_amd64.deb"
ls -l "$OUTDIR"
echo "REMOTE_BUILD_OK ${sha.slice(0, 12)}"
`;
}
const sshOptions = ({ key }) => ['-o', 'BatchMode=yes', '-o', 'IdentitiesOnly=yes', '-o', 'ConnectTimeout=15', '-o', 'ServerAliveInterval=30', ...(key ? ['-i', key] : [])];
/** Runs a command and streams its output; the exit code decides. */
function stream(command, args, { log, label }) {
log(`${label}: ${command} ${args.map(a => (/\s/.test(a) ? '"…"' : a)).join(' ').slice(0, 160)}`);
const result = spawnSync(command, args, { stdio: 'inherit', windowsHide: true });
if (result.error) fail(`${command}: ${result.error.message}`);
if (result.status !== 0) fail(`${label} failed (exit ${result.status})`);
}
/**
* Builds the committed state of the app on a Mac over SSH and brings the .dmg and the .app.tar.gz
* back to `<outDir>/build/macos`. Returns `{ version, sha, dir, dmg, updater }`.
*/
export function buildMacRemote({ config, target, key, remoteDir, env = process.env, log = console.log }) {
if (!target || !/^[A-Za-z0-9._-]+@[A-Za-z0-9._:-]+$/.test(target)) fail('a Mac needs --remote user@host (or WAYBETTER_MAC_SSH)');
const { root } = config;
const version = JSON.parse(committedFile(root, 'package.json')).version;
const productName = JSON.parse(committedFile(root, relTauri(config, 'tauri.conf.json'))).productName;
const workDir = workDirOf(remoteDir, config.artifact);
const dir = join(config.outDir, 'build', 'macos');
mkdirSync(dir, { recursive: true });
for (const name of readdirSync(dir)) rmSync(join(dir, name), { force: true, recursive: true });
const scratch = mkdtempSync(join(tmpdir(), 'wb-mac-'));
try {
const exported = exportCommitted({ root, file: join(scratch, 'source.tgz'), exclude: config.exportExclude, log });
const short = exported.sha.slice(0, 12);
const script = macBuildScript({ workDir, sha: exported.sha, artifact: config.artifact, productName, version, desktopDir: relative(root, config.desktopDir).replaceAll('\\', '/'), prepareCommand: config.prepareCommand });
writeFileSync(join(scratch, 'build.sh'), script.replaceAll('\r\n', '\n'));
const ssh = sshOptions({ key });
stream('ssh', [...ssh, target, `mkdir -p "$HOME/${workDir}" && find "$HOME/${workDir}" -maxdepth 1 -name 'source-*.tgz' -delete`], { log, label: 'mac: prepare' });
stream('scp', [...ssh, join(scratch, 'source.tgz'), `${target}:${workDir}/source-${short}.tgz`], { log, label: 'mac: upload' });
stream('scp', [...ssh, join(scratch, 'build.sh'), `${target}:${workDir}/build.sh`], { log, label: 'mac: upload script' });
const started = Date.now();
// A login shell: bun, cargo and node are set up in the Mac account's profile only.
stream('ssh', [...ssh, target, `zsh -lic "bash $HOME/${workDir}/build.sh"`], { log, label: 'mac: build' });
log(`mac: build took ${Math.round((Date.now() - started) / 1000)} s`);
stream('scp', [...ssh, `${target}:${workDir}/out/${config.artifact}_${version}_universal.dmg`, `${target}:${workDir}/out/${config.artifact}_${version}_universal.app.tar.gz`, dir], { log, label: 'mac: fetch' });
return { version, sha: exported.sha, dir, dmg: join(dir, `${config.artifact}_${version}_universal.dmg`), updater: join(dir, `${config.artifact}_${version}_universal.app.tar.gz`) };
} finally {
rmSync(scratch, { recursive: true, force: true });
}
}
/**
* Builds the committed state of the app in a WSL distro (Ubuntu 22.04: the glibc floor) and puts
* the AppImage and the .deb in `<outDir>/build/linux`. Returns `{ version, sha, dir, appimage, deb }`.
*/
export function buildLinuxWsl({ config, distro = 'Ubuntu-22.04', remoteDir, env = process.env, log = console.log }) {
if (process.platform !== 'win32') fail('--wsl runs from Windows; on Linux run the build on the machine itself');
if (!/^[A-Za-z0-9._-]+$/.test(distro)) fail('bad WSL distro name');
const { root } = config;
const version = JSON.parse(committedFile(root, 'package.json')).version;
const workDir = workDirOf(remoteDir, config.artifact);
const dir = join(config.outDir, 'build', 'linux');
mkdirSync(dir, { recursive: true });
for (const name of readdirSync(dir)) rmSync(join(dir, name), { force: true, recursive: true });
const scratch = mkdtempSync(join(tmpdir(), 'wb-linux-'));
try {
const exported = exportCommitted({ root, file: join(scratch, 'source.tgz'), exclude: config.exportExclude, log });
const script = linuxBuildScript({ workDir, sha: exported.sha, artifact: config.artifact, version, desktopDir: relative(root, config.desktopDir).replaceAll('\\', '/'), prepareCommand: config.prepareCommand, tarball: join(scratch, 'source.tgz'), outDir: dir });
writeFileSync(join(scratch, 'build.sh'), script.replaceAll('\r\n', '\n'));
const started = Date.now();
// MSYS_NO_PATHCONV: Git Bash must not rewrite the /mnt paths of the script.
const wslScript = `/mnt/${scratch[0].toLowerCase()}/${scratch.slice(3).replaceAll('\\', '/')}/build.sh`;
const result = spawnSync('wsl', ['-d', distro, '--', 'bash', wslScript], { stdio: 'inherit', windowsHide: true, env: { ...env, MSYS_NO_PATHCONV: '1' } });
if (result.status !== 0) fail(`linux build in WSL ${distro} failed (exit ${result.status})`);
log(`linux: build took ${Math.round((Date.now() - started) / 1000)} s`);
return { version, sha: exported.sha, dir, appimage: join(dir, `${config.artifact}_${version}_amd64.AppImage`), deb: join(dir, `${config.artifact}_${version}_amd64.deb`) };
} finally {
rmSync(scratch, { recursive: true, force: true });
}
}
const relTauri = (config, name) => `${relative(config.root, config.tauriDir).replaceAll('\\', '/')}/${name}`;
/** Scans built artifacts here (the terms never leave this machine) and prints their sizes. */
export function scanBuilt(config, files, { env = process.env, log = console.log } = {}) {
for (const file of files) {
if (!existsSync(file)) fail(`expected build output missing: ${file}`);
log(`built: ${basename(file)} ${(statSync(file).size / 1048576).toFixed(1)} MB`);
}
assertClean(files, { terms: config.forbiddenTerms, allowlist: config.privacyAllowlist, cwd: config.root, env, log, label: 'release: privacy-scan' });
}

View file

@ -0,0 +1,116 @@
// Remote and WSL builds: the export of the committed state and the scripts that build it.
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import { committedSubmodules, exportCommitted, linuxBuildScript, macBuildScript, shq } from '../cli/remote.mjs';
import { releaseCommand } from '../cli/release.mjs';
const git = (cwd, ...args) => execFileSync('git', ['-c', 'commit.gpgsign=false', '-c', 'core.hooksPath=NUL', '-c', 'user.name=t', '-c', 'user.email=t@example.test', '-c', 'protocol.file.allow=always', ...args], { cwd, encoding: 'utf8', stdio: 'pipe' }).trim();
/** An app repo with a submodule whose recorded commit is NOT the submodule's newest one. */
function repo() {
const root = mkdtempSync(join(tmpdir(), 'wb-remote-'));
const sub = join(root, 'fw');
mkdirSync(sub);
git(sub, 'init', '-q');
writeFileSync(join(sub, 'lib.txt'), 'framework at the pointer\n');
git(sub, 'add', '.');
git(sub, 'commit', '-q', '-m', 'one');
const pointer = git(sub, 'rev-parse', 'HEAD');
git(root, 'init', '-q');
writeFileSync(join(root, 'app.txt'), 'committed app\n');
writeFileSync(join(root, 'big.txt'), 'left out\n');
mkdirSync(join(root, 'shots'));
writeFileSync(join(root, 'shots', 'a.png'), 'png');
git(root, 'add', 'app.txt', 'big.txt', 'shots');
git(root, 'update-index', '--add', '--cacheinfo', `160000,${pointer},fw`);
git(root, 'commit', '-q', '-m', 'app');
// Work after the commit: none of it may reach the export.
writeFileSync(join(sub, 'lib.txt'), 'framework NEWER, uncommitted\n');
writeFileSync(join(sub, 'later.txt'), 'later commit\n');
git(sub, 'add', '.');
git(sub, 'commit', '-q', '-m', 'two');
writeFileSync(join(root, 'app.txt'), 'edited in the working tree\n');
writeFileSync(join(root, 'untracked.txt'), 'x');
return { root, pointer };
}
const list = file => execFileSync('tar', ['-tzf', 'x.tgz'], { cwd: join(file, '..'), encoding: 'utf8' }).split('\n').map(line => line.replace(/^\.\//, '')).filter(Boolean);
const extract = (file, path) => execFileSync('tar', ['-xzOf', 'x.tgz', `./${path}`], { cwd: join(file, '..'), encoding: 'utf8' });
test('export: HEAD and the submodule at its recorded commit, never the working tree, nothing from origin', () => {
const { root, pointer } = repo();
assert.deepEqual(committedSubmodules(root), [{ path: 'fw', sha: pointer }]);
const file = join(root, '..', `x-${Date.now()}`, 'x.tgz');
const logs = [];
const out = exportCommitted({ root, file, log: line => logs.push(line) });
assert.ok(out.dirty >= 1);
assert.match(logs[0], /\d+ tracked file\(s\) changed in the working tree are NOT included/);
const names = list(file);
assert.ok(names.includes('app.txt') && names.includes('fw/lib.txt'));
assert.ok(!names.includes('untracked.txt') && !names.includes('fw/later.txt'));
assert.equal(extract(file, 'app.txt'), 'committed app\n');
assert.equal(extract(file, 'fw/lib.txt'), 'framework at the pointer\n');
});
test('export: excluded paths stay out of the upload', () => {
const { root } = repo();
const file = join(root, '..', `x-${Date.now()}-b`, 'x.tgz');
exportCommitted({ root, file, exclude: ['shots'] });
const names = list(file);
assert.ok(names.includes('big.txt'));
assert.ok(!names.some(name => name.startsWith('shots')));
});
test('export: a submodule commit missing locally is an error that says so', () => {
const { root } = repo();
git(root, 'update-index', '--cacheinfo', `160000,${'1'.repeat(40)},fw`);
git(root, 'commit', '-q', '-m', 'bad pointer');
assert.throws(() => exportCommitted({ root, file: join(root, '..', `x-${Date.now()}-c`, 'x.tgz') }), /local repository/);
});
const MAC = { workDir: 'waybetter-builds/App', sha: 'abcdef0123456789abcdef0123456789abcdef01', artifact: 'App', productName: 'My App', version: '1.2.3', desktopDir: 'apps/desktop', prepareCommand: 'bun install --frozen-lockfile' };
test('mac script: rustup first, ad-hoc universal build through build-env, updater bundle off, artifacts named', () => {
const script = macBuildScript(MAC);
assert.match(script, /export PATH="\$HOME\/\.cargo\/bin:\$PATH"/);
assert.match(script, /renice -n 10/);
assert.match(script, /rustup target add aarch64-apple-darwin x86_64-apple-darwin/);
assert.match(script, /waybetter\.mjs" build-env -- bunx tauri build --target universal-apple-darwin --bundles app,dmg --ci/);
assert.match(script, /"createUpdaterArtifacts":false/);
assert.match(script, /source-abcdef012345\.tgz/);
assert.match(script, /App_1\.2\.3_universal\.dmg/);
assert.match(script, /COPYFILE_DISABLE=1 tar -czf "\$OUT\/App_1\.2\.3_universal\.app\.tar\.gz" -C "\$BUNDLE\/macos" 'My App\.app'/);
assert.ok(script.includes('ditto "$BUNDLE/macos/"\'My App.app\' "$OUT/"\'My App.app\''));
assert.doesNotMatch(script, /pkill|Applications|osascript/);
assert.doesNotMatch(script, /TAURI_SIGNING_PRIVATE_KEY/);
});
test('linux script: clean PATH (no Windows programs), AppImage and deb through build-env', () => {
const script = linuxBuildScript({ ...MAC, tarball: 'C:\\t\\source.tgz', outDir: 'C:\\o\\linux' });
assert.match(script, /export PATH="\$HOME\/\.bun\/bin:\$HOME\/\.cargo\/bin:\/usr\/local\/sbin/);
assert.doesNotMatch(script, /\/mnt\/c/);
assert.match(script, /--bundles appimage,deb --ci/);
assert.match(script, /App_1\.2\.3_amd64\.AppImage/);
assert.match(script, /wslpath -u 'C:\\t\\source\.tgz'/);
});
test('shq quotes single quotes', () => {
assert.equal(shq("it's"), `'it'\\''s'`);
});
test('linux release: usage errors and the missing-manifest order', async () => {
const root = mkdtempSync(join(tmpdir(), 'wb-linux-'));
const tauri = join(root, 'apps/desktop/src-tauri');
mkdirSync(tauri, { recursive: true });
writeFileSync(join(root, 'waybetter.release.json'), JSON.stringify({ product: 'P', artifact: 'P', desktopDir: 'apps/desktop', keysDir: 'nokeys', cdnBase: 'https://c.example.test', apiBase: 'https://a.example.test' }));
writeFileSync(join(root, 'package.json'), JSON.stringify({ version: '1.0.0' }));
await assert.rejects(releaseCommand(['linux'], { cwd: root, log: () => {} }), /usage: --appimage/);
writeFileSync(join(root, 'a.AppImage'), 'x');
writeFileSync(join(root, 'a.zip'), 'x');
await assert.rejects(releaseCommand(['linux', '--appimage', join(root, 'a.AppImage'), '--deb', join(root, 'a.zip')], { cwd: root, log: () => {} }), /\.deb/);
assert.ok(!existsSync(join(root, 'releases')));
});