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:
parent
f8144a3bff
commit
1b0a489b36
4 changed files with 477 additions and 18 deletions
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
256
packages/waybetter/cli/remote.mjs
Normal file
256
packages/waybetter/cli/remote.mjs
Normal 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' });
|
||||||
|
}
|
||||||
116
packages/waybetter/tests/remote-cli.test.mjs
Normal file
116
packages/waybetter/tests/remote-cli.test.mjs
Normal 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')));
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue