release: the Linux / Windows updater signatures are made on the VDS (desktop-release add-remote, vds-key; tools/vds-sign.ts); the Mac no longer downloads those installers, verifies each returned .sig over its BLAKE2b-512 digest before publishing; release.ts --sign-local keeps the old path
This commit is contained in:
parent
d94bf67340
commit
ef3c66e8f0
6 changed files with 444 additions and 51 deletions
|
|
@ -16,6 +16,12 @@
|
|||
// folder: privacy scan, minisign signature here (the key never goes to a runner), latest.json, SHA256SUMS and
|
||||
// evidence-<linux|windows>-<version>.json (the run and commit they came from; a commit other than the one of the
|
||||
// folder's other evidence is refused: every artifact of a version comes from one commit).
|
||||
// bun tools/desktop-release.ts add-remote --out <dir> --host <user@host> --assets <json> [--commit <sha>] [--run <url>] [--code-signed]
|
||||
// The default since 21 Oct 2026: the VDS fetches the Linux / Windows files of the Forgejo release into its incoming
|
||||
// folder, checks their SHA-256, signs them with its copy of the updater key (tools/vds-sign.ts); only the .sig
|
||||
// texts come back and are verified here. The files stay on the VDS (.remote.json); publish checks them there.
|
||||
// bun tools/desktop-release.ts vds-key [--host root@<host>]
|
||||
// Installs or rotates the updater key in /root/.storebound-keys on the VDS and verifies a test signature here.
|
||||
// bun tools/desktop-release.ts publish --out <dir> --host <user@host> [--channel stable|beta|rehearsal]
|
||||
// [--forgejo-tag v<version>] [--yes]
|
||||
// Checks the folder again, uploads it to the server's incoming folder (rsync) and runs the root-owned publish
|
||||
|
|
@ -45,6 +51,7 @@ import { buildOne, OUT_DIR as SIDECAR_DIR } from './build-sidecars';
|
|||
import { versions } from './desktop-version';
|
||||
import { listReleases, upsert as forgejoUpsert } from './forgejo-release';
|
||||
import { isMacArtifact, scanSignedMac } from './mac-signature-scan';
|
||||
import { parseSigLines, REMOTE_KEY, REMOTE_KEY_PASSWORD, REMOTE_KEYS, type RemoteItem, remoteSigProblems, remoteSignScript, signCommand, sshScript, verifyDigest } from './vds-sign';
|
||||
import { allTerms, DEFAULT_TERMS, type Report, scan, TERMS_ENV } from './privacy-scan';
|
||||
|
||||
export const ROOT = resolve(import.meta.dir, '..');
|
||||
|
|
@ -248,6 +255,15 @@ function privacyGate(paths: string[]): { files: number; bytes: number; hits: num
|
|||
return { files: r.files, bytes: r.bytes, hits: 0 };
|
||||
}
|
||||
|
||||
/** Files of the version folder that live only on the VDS (signed there, tools/vds-sign.ts): `.remote.json`, never
|
||||
* uploaded. Their SHA-256 was checked on the VDS against the candidate's record; BLAKE2b-512 is what was signed. */
|
||||
export interface RemoteFile { file: string; sha256: string; size: number; blake2b: string }
|
||||
const REMOTE_JSON = '.remote.json';
|
||||
function readRemote(out: string): RemoteFile[] {
|
||||
const p = join(out, REMOTE_JSON);
|
||||
return existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) as RemoteFile[] : [];
|
||||
}
|
||||
|
||||
function readManifest(out: string): Manifest | null {
|
||||
const p = join(out, 'latest.json');
|
||||
return existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) as Manifest : null;
|
||||
|
|
@ -258,8 +274,9 @@ function writeVersionFiles(out: string, manifest: Manifest): void {
|
|||
if (problems.length) fail(`latest.json: ${problems.join('; ')}`);
|
||||
writeFileSync(join(out, 'latest.json.part'), `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
renameSync(join(out, 'latest.json.part'), join(out, 'latest.json'));
|
||||
const files = readdirSync(out).filter(n => uploadable(n) && n !== 'SHA256SUMS');
|
||||
writeFileSync(join(out, 'SHA256SUMS'), sumsText(files.map(file => ({ file, sha256: sha256(join(out, file)) }))));
|
||||
const remote = readRemote(out);
|
||||
const files = readdirSync(out).filter(n => uploadable(n) && n !== 'SHA256SUMS' && !remote.some(r => r.file === n));
|
||||
writeFileSync(join(out, 'SHA256SUMS'), sumsText([...files.map(file => ({ file, sha256: sha256(join(out, file)) })), ...remote.map(r => ({ file: r.file, sha256: r.sha256 }))]));
|
||||
}
|
||||
|
||||
function gitState(): { commit: string; dirty: boolean } {
|
||||
|
|
@ -643,90 +660,187 @@ export function commitProblems(commit: string | undefined, evidence: Record<stri
|
|||
return Object.entries(evidence).filter(([, c]) => c && c !== commit).map(([f, c]) => `${f} says commit ${c!.slice(0, 10)}, these files are from ${commit.slice(0, 10)}`);
|
||||
}
|
||||
|
||||
function add(argv: string[]): void {
|
||||
/** The Linux / Windows artifact name of a workflow file (`_<version>_` in it, kind from the extension). */
|
||||
function addedName(n: string, version: string, codeSigned: boolean): string {
|
||||
if (!n.includes(`_${version}_`)) fail(`${n} is not version ${version}`);
|
||||
const kind = /-setup\.exe$/i.test(n) ? 'nsis' : /\.AppImage$/.test(n) ? 'appimage' : /\.deb$/.test(n) ? 'deb' : null;
|
||||
if (!kind) fail(`${n}: expected an NSIS setup .exe, an .AppImage or a .deb`);
|
||||
return artifactName(version, kind, kind === 'nsis' ? codeSigned : true);
|
||||
}
|
||||
|
||||
function addPrelude(argv: string[]) {
|
||||
const a = args(argv);
|
||||
const { version, drift } = versions();
|
||||
if (!version || drift.length) fail('desktop version drift (bun tools/desktop-version.ts)');
|
||||
const out = resolve(a.get('out') ?? join(homedir(), '.storebound-releases', 'desktop', version));
|
||||
const files = a.rest.slice(1);
|
||||
if (!files.length) fail('usage: add --out <dir> <setup.exe | .AppImage | .deb ...>');
|
||||
mkdirSync(out, { recursive: true });
|
||||
const commit = a.get('commit');
|
||||
const otherEvidence = Object.fromEntries(readdirSync(out).filter(n => /^evidence-.*\.json$/.test(n))
|
||||
.map(n => [n, (JSON.parse(readFileSync(join(out, n), 'utf8')) as { commit?: string }).commit]));
|
||||
const commitBad = commitProblems(commit, otherEvidence);
|
||||
if (commitBad.length) fail(`not one commit: ${commitBad.join('; ')}`);
|
||||
const codeSigned = a.has('code-signed');
|
||||
return { a, version, out, commit, codeSigned: a.has('code-signed') };
|
||||
}
|
||||
|
||||
interface Added { name: string; from: string; sha256: string; size: number; sig: string }
|
||||
|
||||
/** latest.json, SHA256SUMS and evidence-<platform>-<version>.json for added Linux / Windows files. */
|
||||
function recordAdded(p: ReturnType<typeof addPrelude>, added: Added[], checks: Record<string, unknown>): void {
|
||||
const { a, version, out, commit, codeSigned } = p;
|
||||
const platforms: Manifest['platforms'] = {};
|
||||
const downloads: Partial<Record<DownloadKey, Download>> = {};
|
||||
const added: string[] = [];
|
||||
const sources: Record<string, string> = {};
|
||||
for (const f of files.map(x => resolve(x))) {
|
||||
const n = basename(f);
|
||||
if (!n.includes(`_${version}_`)) fail(`${n} is not version ${version}`);
|
||||
const kind = /-setup\.exe$/i.test(n) ? 'nsis' : /\.AppImage$/.test(n) ? 'appimage' : /\.deb$/.test(n) ? 'deb' : null;
|
||||
if (!kind) fail(`${n}: expected an NSIS setup .exe, an .AppImage or a .deb`);
|
||||
const name = artifactName(version, kind, kind === 'nsis' ? codeSigned : true);
|
||||
const dest = join(out, name);
|
||||
copyFileSync(f, dest);
|
||||
added.push(dest);
|
||||
sources[name] = n;
|
||||
}
|
||||
const privacy = privacyGate(added);
|
||||
for (const dest of added) {
|
||||
const name = basename(dest);
|
||||
const sig = signFile(dest);
|
||||
const d: Download = { file: name, url: releaseUrl(version, name), sha256: sha256(dest), size: statSync(dest).size, signature: sig, codeSigned: name.endsWith('.exe') ? codeSigned : false };
|
||||
if (name.endsWith('.exe')) { downloads['windows-x86_64-nsis'] = d; platforms['windows-x86_64'] = { signature: sig, url: d.url }; }
|
||||
else if (name.endsWith('.AppImage')) { downloads['linux-x86_64-appimage'] = d; platforms['linux-x86_64'] = { signature: sig, url: d.url }; }
|
||||
for (const x of added) {
|
||||
const d: Download = { file: x.name, url: releaseUrl(version, x.name), sha256: x.sha256, size: x.size, signature: x.sig, codeSigned: x.name.endsWith('.exe') ? codeSigned : false };
|
||||
if (x.name.endsWith('.exe')) { downloads['windows-x86_64-nsis'] = d; platforms['windows-x86_64'] = { signature: x.sig, url: d.url }; }
|
||||
else if (x.name.endsWith('.AppImage')) { downloads['linux-x86_64-appimage'] = d; platforms['linux-x86_64'] = { signature: x.sig, url: d.url }; }
|
||||
else downloads['linux-x86_64-deb'] = d;
|
||||
say(`added ${name} (${mb(d.size)})`);
|
||||
say(`added ${x.name} (${mb(x.size)})`);
|
||||
}
|
||||
const existing = readManifest(out);
|
||||
const now = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||
writeVersionFiles(out, mergeManifest(existing, version, a.get('notes') ?? existing?.notes ?? `storebound desktop ${version}`, now, { platforms, downloads }));
|
||||
// evidence per platform (stays local, like the macOS one): where the files came from and what was checked here
|
||||
for (const platform of ['linux', 'windows'] as const) {
|
||||
const mine = added.filter(d => (platform === 'windows') === d.endsWith('.exe'));
|
||||
const mine = added.filter(d => (platform === 'windows') === d.name.endsWith('.exe'));
|
||||
if (!mine.length) continue;
|
||||
const evidence = {
|
||||
product: 'storebound desktop', version, platform: `${platform}-x86_64`, addedAt: now, commit: commit ?? null, run: a.get('run') ?? null,
|
||||
checks: { privacy, updaterSignature: 'minisign, verified against tauri.conf.json', codeSigned: platform === 'windows' ? codeSigned : false },
|
||||
artifacts: mine.map(d => ({ file: basename(d), from: sources[basename(d)], size: statSync(d).size, sha256: sha256(d) })),
|
||||
checks: { ...checks, codeSigned: platform === 'windows' ? codeSigned : false },
|
||||
artifacts: mine.map(d => ({ file: d.name, from: d.from, size: d.size, sha256: d.sha256 })),
|
||||
};
|
||||
writeFileSync(join(out, `evidence-${platform}-${version}.json`), `${JSON.stringify(evidence, null, 2)}\n`);
|
||||
}
|
||||
say(`latest.json now has ${Object.keys(readManifest(out)!.platforms).join(', ')}${commit ? `; evidence: commit ${commit.slice(0, 10)}` : ''}`);
|
||||
}
|
||||
|
||||
/** The fallback (--sign-local in tools/release.ts): the files are on this Mac and signed with ~/.storebound-keys. */
|
||||
function add(argv: string[]): void {
|
||||
const p = addPrelude(argv);
|
||||
const files = p.a.rest.slice(1);
|
||||
if (!files.length) fail('usage: add --out <dir> <setup.exe | .AppImage | .deb ...>');
|
||||
const copied: { dest: string; from: string }[] = [];
|
||||
for (const f of files.map(x => resolve(x))) {
|
||||
const dest = join(p.out, addedName(basename(f), p.version, p.codeSigned));
|
||||
copyFileSync(f, dest);
|
||||
copied.push({ dest, from: basename(f) });
|
||||
}
|
||||
const privacy = privacyGate(copied.map(c => c.dest));
|
||||
const added = copied.map(c => ({ name: basename(c.dest), from: c.from, sha256: sha256(c.dest), size: statSync(c.dest).size, sig: signFile(c.dest) }));
|
||||
recordAdded(p, added, { privacy, updaterSignature: 'minisign on the Mac, verified against tauri.conf.json' });
|
||||
}
|
||||
|
||||
/** The VDS signs (owner's decision 21 Oct 2026): `add-remote --out <dir> --host <user@host> --assets <json>` with
|
||||
* [{name, url, sha256, size}] of the Forgejo release. The VDS fetches each file into its incoming folder, checks
|
||||
* the SHA-256 and signs it; only the .sig texts come back, each verified here before anything is written. */
|
||||
async function addRemote(argv: string[]): Promise<void> {
|
||||
const p = addPrelude(argv);
|
||||
const host = p.a.get('host') ?? fail('--host <user@host> is required');
|
||||
if (!/^[A-Za-z0-9._-]+@[A-Za-z0-9.-]+$/.test(host)) fail(`--host: user@host, got ${host}`);
|
||||
const assets = JSON.parse(readFileSync(p.a.get('assets') ?? fail('--assets <json file> is required'), 'utf8')) as { name: string; url: string; sha256: string; size: number }[];
|
||||
if (!assets.length) fail('--assets: no files');
|
||||
const items: (RemoteItem & { from: string; size: number })[] = assets.map(x => ({ file: addedName(x.name, p.version, p.codeSigned), url: x.url, sha256: x.sha256, from: x.name, size: x.size }));
|
||||
const dir = `${REMOTE_INCOMING}/${p.version}`;
|
||||
say(`VDS ${host.split('@')[1]}: fetch ${items.length} files from Forgejo into ${dir}, check SHA-256, sign with ${REMOTE_KEY}`);
|
||||
const outText = await sshScript(host, remoteSignScript(dir, items), { timeoutMs: 30 * 60_000, what: 'VDS fetch + sign', log: say }).catch((e: Error) => fail(e.message));
|
||||
const got = parseSigLines(outText);
|
||||
const problems = remoteSigProblems(items.map(i => i.file), got, confPubkey());
|
||||
if (problems.length) fail(`signatures from the VDS refused, nothing written: ${problems.join('; ')}`);
|
||||
const remote: RemoteFile[] = [];
|
||||
const added: Added[] = items.map(i => {
|
||||
const g = got.find(x => x.file === i.file)!;
|
||||
if (i.size && g.size !== i.size) fail(`${i.file}: ${g.size} bytes on the VDS, the release says ${i.size}`);
|
||||
remote.push({ file: i.file, sha256: i.sha256, size: g.size, blake2b: g.blake2b });
|
||||
writeFileSync(join(p.out, `${i.file}.sig`), g.signature);
|
||||
rmSync(join(p.out, i.file), { force: true }); // a stale local copy from a --sign-local run never wins
|
||||
return { name: i.file, from: i.from, sha256: i.sha256, size: g.size, sig: g.signature };
|
||||
});
|
||||
const prev = readRemote(p.out).filter(r => !remote.some(x => x.file === r.file));
|
||||
writeFileSync(join(p.out, REMOTE_JSON), `${JSON.stringify([...prev, ...remote], null, 2)}\n`);
|
||||
say(`${added.length} signatures from the VDS verified against tauri.conf.json's key`);
|
||||
recordAdded(p, added, { privacy: 'scanned in the build jobs (ci.yml, private terms)', updaterSignature: 'minisign on the VDS, verified on the Mac against tauri.conf.json (BLAKE2b-512 digest)', sha256: 'checked on the VDS against the candidate record' });
|
||||
}
|
||||
|
||||
/** `vds-key --host <user@host>`: installs or updates the updater key (and its password file, if
|
||||
* ~/.storebound-keys/updater.key.password exists) in /root/.storebound-keys on the VDS (dir 0700, files 0600, root),
|
||||
* over ssh stdin (never printed, never on a command line), then signs a test string there and verifies it here. */
|
||||
async function vdsKey(argv: string[]): Promise<void> {
|
||||
const a = args(argv);
|
||||
const host = a.get('host') ?? process.env.STOREBOUND_PUBLISH_HOST ?? (existsSync(join(KEYS_DIR, 'publish-host')) ? readFileSync(join(KEYS_DIR, 'publish-host'), 'utf8').trim() : '');
|
||||
if (!/^root@[A-Za-z0-9.-]+$/.test(host)) fail(`the VDS host must be root@<host> (--host, STOREBOUND_PUBLISH_HOST or ~/.storebound-keys/publish-host), got ${host || 'nothing'}`);
|
||||
const key = updaterKey();
|
||||
const keyB64 = readFileSync(key).toString('base64');
|
||||
const pwFile = `${key}.password`;
|
||||
const pwB64 = existsSync(pwFile) ? readFileSync(pwFile).toString('base64') : '';
|
||||
const test = `storebound-vds-key-check-${Date.now()}`;
|
||||
const t = `/tmp/sb-key-check-${process.pid}`;
|
||||
const script = [
|
||||
'set -euo pipefail', 'umask 077',
|
||||
`install -d -m 700 -o root -g root ${REMOTE_KEYS}`,
|
||||
`base64 -d > ${REMOTE_KEY}.new <<'K'`, keyB64, 'K',
|
||||
`chmod 600 ${REMOTE_KEY}.new && chown root:root ${REMOTE_KEY}.new && mv -f ${REMOTE_KEY}.new ${REMOTE_KEY}`,
|
||||
pwB64 ? `base64 -d > ${REMOTE_KEY_PASSWORD}.new <<'K'
|
||||
${pwB64}
|
||||
K
|
||||
chmod 600 ${REMOTE_KEY_PASSWORD}.new && mv -f ${REMOTE_KEY_PASSWORD}.new ${REMOTE_KEY_PASSWORD}` : `rm -f ${REMOTE_KEY_PASSWORD}`,
|
||||
`chmod 700 ${REMOTE_KEYS}`,
|
||||
`mkdir -p ${t} && printf '%s' '${test}' > ${t}/check.txt`,
|
||||
'echo "signing a test string (the first run downloads the tauri CLI)"',
|
||||
`${signCommand(`${t}/check.txt`)}`,
|
||||
`printf 'SIG %s %s %s %s\n' check.txt "$(b2sum -l 512 ${t}/check.txt | cut -d' ' -f1)" "$(stat -c %s ${t}/check.txt)" "$(tr -d '\n' < ${t}/check.txt.sig)"`,
|
||||
`rm -rf ${t}`,
|
||||
`stat -c 'MODE %a %U %n' ${REMOTE_KEYS} ${REMOTE_KEYS}/*`,
|
||||
].join('\n');
|
||||
say(`installing the updater key on ${host.split('@')[1]}:${REMOTE_KEYS} (one ssh connection)`);
|
||||
const out = await sshScript(host, `${script}\n`, { timeoutMs: 10 * 60_000, what: 'vds-key', log: say }).catch((e: Error) => fail(e.message));
|
||||
const modes = out.split('\n').filter(l => l.startsWith('MODE '));
|
||||
const bad = modes.filter(l => !/^MODE (700|600) root /.test(l));
|
||||
if (!modes.length || bad.length) fail(`unexpected modes on the VDS: ${bad.join('; ') || 'none read'}`);
|
||||
const [sig] = parseSigLines(out);
|
||||
const blake = createHash('blake2b512').update(test).digest('hex');
|
||||
if (!sig || sig.blake2b !== blake) fail('the VDS did not sign the test string');
|
||||
const v = verifyDigest(blake, sig.signature, confPubkey(), 'check.txt');
|
||||
if (!v.ok) fail(`the VDS's test signature does not verify against tauri.conf.json: ${v.reason}`);
|
||||
say(`ok: ${modes.length} paths 0700/0600 root; the VDS signs with the key of tauri.conf.json (test string verified here)`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// verify / publish
|
||||
|
||||
function verifyFolder(out: string): { manifest: Manifest; files: string[] } {
|
||||
function verifyFolder(out: string): { manifest: Manifest; files: string[]; remote: RemoteFile[] } {
|
||||
if (!existsSync(join(out, 'latest.json')) || !existsSync(join(out, 'SHA256SUMS'))) fail(`${out}: no latest.json / SHA256SUMS`);
|
||||
const manifest = readManifest(out)!;
|
||||
const problems = manifestProblems(manifest);
|
||||
if (problems.length) fail(`latest.json: ${problems.join('; ')}`);
|
||||
const sums = readFileSync(join(out, 'SHA256SUMS'), 'utf8').trim().split('\n').map(l => { const [h, f] = l.split(/\s+/); return { h: h!, f: f! }; });
|
||||
for (const { h, f } of sums) if (!existsSync(join(out, f)) || sha256(join(out, f)) !== h) fail(`SHA256SUMS: ${f} does not match`);
|
||||
const files = readdirSync(out).filter(uploadable);
|
||||
const remote = readRemote(out);
|
||||
const remoteOf = (f: string) => remote.find(r => r.file === f);
|
||||
for (const { h, f } of sums) {
|
||||
const r = remoteOf(f);
|
||||
if (r ? r.sha256 !== h : (!existsSync(join(out, f)) || sha256(join(out, f)) !== h)) fail(`SHA256SUMS: ${f} does not match`);
|
||||
}
|
||||
const files = [...new Set([...readdirSync(out).filter(uploadable), ...remote.map(r => r.file)])].sort();
|
||||
for (const f of files) if (f !== 'SHA256SUMS' && !sums.some(s => s.f === f)) fail(`${f} is not in SHA256SUMS`);
|
||||
const pub = confPubkey();
|
||||
const check = (url: string, signature: string, what: string) => {
|
||||
const file = url.split('/').pop()!;
|
||||
if (!url.startsWith(releaseUrl(manifest.version, ''))) fail(`${what}: ${url} is not in /desktop/dl/${manifest.version}/`);
|
||||
if (!files.includes(file)) fail(`${what}: ${file} is not in the folder`);
|
||||
const v = verifySig(readFileSync(join(out, file)), signature, pub);
|
||||
const r = remoteOf(file);
|
||||
const v = r ? verifyDigest(r.blake2b, signature, pub, file) : verifySig(readFileSync(join(out, file)), signature, pub);
|
||||
if (!v.ok) fail(`${what}: ${file}: ${v.reason}`);
|
||||
};
|
||||
for (const [k, p] of Object.entries(manifest.platforms)) check(p.url, p.signature, `platforms.${k}`);
|
||||
for (const [k, d] of Object.entries(manifest.downloads ?? {})) {
|
||||
check(d!.url, d!.signature, `downloads.${k}`);
|
||||
if (sha256(join(out, d!.file)) !== d!.sha256 || statSync(join(out, d!.file)).size !== d!.size) fail(`downloads.${k}: size or SHA-256 differ from the file`);
|
||||
const r = remoteOf(d!.file);
|
||||
const [h, size] = r ? [r.sha256, r.size] : [sha256(join(out, d!.file)), statSync(join(out, d!.file)).size];
|
||||
if (h !== d!.sha256 || size !== d!.size) fail(`downloads.${k}: size or SHA-256 differ from the file`);
|
||||
}
|
||||
privacyGate(files.filter(f => !/\.(sig|json)$|^SHA256SUMS$/.test(f)).map(f => join(out, f)));
|
||||
// the VDS-signed files were scanned in their build jobs (ci.yml, private terms) and are not on this Mac
|
||||
privacyGate(files.filter(f => !remoteOf(f) && !/\.(sig|json)$|^SHA256SUMS$/.test(f)).map(f => join(out, f)));
|
||||
if (remote.length) say(`on the VDS only (signed there, SHA-256 checked there against the candidate): ${remote.map(r => r.file).join(', ')}`);
|
||||
say(`verified ${manifest.version}: ${files.length} files, platforms ${Object.keys(manifest.platforms).join(', ')}, downloads ${Object.keys(manifest.downloads ?? {}).join(', ')}`);
|
||||
return { manifest, files };
|
||||
return { manifest, files, remote };
|
||||
}
|
||||
|
||||
/** The npm CLI and the desktop app are one release: the folder must be the version of this checkout's package.json
|
||||
|
|
@ -781,7 +895,9 @@ async function publish(argv: string[]): Promise<void> {
|
|||
if (!/^[A-Za-z0-9._-]+@[A-Za-z0-9.-]+$/.test(host)) fail(`--host: user@host, got ${host}`);
|
||||
const channel = a.get('channel') ?? 'stable';
|
||||
if (!(CHANNELS as readonly string[]).includes(channel)) fail(`--channel ${CHANNELS.join(', ')}`);
|
||||
const { manifest, files } = verifyFolder(out);
|
||||
const { manifest, files: all, remote } = verifyFolder(out);
|
||||
// files signed on the VDS are already in its incoming folder: never rsynced or seeded, only checked there
|
||||
const files = all.filter(f => !remote.some(r => r.file === f));
|
||||
const v = manifest.version;
|
||||
const together = releaseTogetherProblems(v, versions().version);
|
||||
if (together.length) fail(together.join('; '));
|
||||
|
|
@ -803,6 +919,7 @@ async function publish(argv: string[]): Promise<void> {
|
|||
if (unsigned.length) console.log(` note: unsigned preview builds (${unsigned.join(', ')}); the downloads page says so.`);
|
||||
console.log('\nsteps:');
|
||||
for (const s of steps) console.log(` ${s[0]} ${s.slice(1).map(x => (x.includes(' ') ? `"${x}"` : x.replace(out, '<out>'))).join(' ')}`);
|
||||
if (remote.length) console.log(` already on the server (signed there), SHA-256 checked again: ${remote.map(r => r.file).join(', ')}`);
|
||||
if (seed.length) console.log(` the server fetches from Forgejo ${forgejoTag}: ${seed.map(x => x.file).join(', ')}`);
|
||||
if (macFiles.length) console.log(` then attaches to Forgejo ${forgejoTag} from its copy: ${macFiles.join(', ')}`);
|
||||
if (!a.has('yes')) { console.log('\ndry run: nothing uploaded. Every installed app on this channel offers the update once latest.json is live; run again with --yes to publish.'); return; }
|
||||
|
|
@ -816,6 +933,12 @@ async function publish(argv: string[]): Promise<void> {
|
|||
// the script goes to bash on stdin: the server's login shell may be zsh, which does not split "$pids"
|
||||
run(['ssh', host, 'bash', '-s'], { input: `pids=''\n${lines.map(l => `(${l}) & pids="$pids $!"`).join('\n')}\nfor p in $pids; do wait $p || exit 1; done\n`, stdio: ['pipe', 'inherit', 'inherit'] });
|
||||
}
|
||||
if (remote.length) {
|
||||
const dir = `${REMOTE_INCOMING}/${v}`;
|
||||
say(`server: SHA-256 of the ${remote.length} files signed there`);
|
||||
await sshScript(host, `set -euo pipefail\n${remote.map(r => `echo '${r.sha256} ${dir}/${r.file}' | sha256sum -c --quiet || { echo "${r.file} on the server is not the signed file"; exit 1; }`).join('\n')}\n`, { timeoutMs: 5 * 60_000, what: 'remote SHA-256 check', log: say })
|
||||
.catch((e: Error) => fail(e.message));
|
||||
}
|
||||
say(`rsync (only what the server does not have yet) ...`);
|
||||
run(rsync, { stdio: ['ignore', 'inherit', 'inherit'] });
|
||||
if (macFiles.length) {
|
||||
|
|
@ -860,6 +983,8 @@ if (import.meta.main) {
|
|||
const cmd = argv[0];
|
||||
if (cmd === 'mac') await mac(argv.slice(1));
|
||||
else if (cmd === 'add') add(argv);
|
||||
else if (cmd === 'add-remote') await addRemote(argv);
|
||||
else if (cmd === 'vds-key') await vdsKey(argv);
|
||||
else if (cmd === 'verify') verifyFolder(resolve(args(argv).get('out') ?? fail('--out <version folder>')));
|
||||
else if (cmd === 'publish') await publish(argv.slice(1));
|
||||
else if (cmd === 'upload-forgejo') await uploadForgejo(argv.slice(1));
|
||||
|
|
@ -870,6 +995,6 @@ if (import.meta.main) {
|
|||
const r = spawnSync(cmdline[0]!, cmdline.slice(1), { stdio: 'inherit', env: { ...process.env, ...pathFreeEnv(ROOT, process.env) }, shell: process.platform === 'win32' });
|
||||
process.exit(r.status ?? 1);
|
||||
}
|
||||
else { console.log(readFileSync(import.meta.path, 'utf8').split('\n').filter(l => l.startsWith('//')).slice(0, 30).map(l => l.slice(3)).join('\n')); process.exit(cmd ? 2 : 0); }
|
||||
else { console.log(readFileSync(import.meta.path, 'utf8').split('\n').filter(l => l.startsWith('//')).slice(0, 40).map(l => l.slice(3)).join('\n')); process.exit(cmd ? 2 : 0); }
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue