Some checks failed
morphit-release / Build + publish release tarball (push) Has been cancelled
366 lines
20 KiB
TypeScript
366 lines
20 KiB
TypeScript
/**
|
|
* upgrade-mirror-smoke (beta5).
|
|
*
|
|
* Covers the new mirror-fallback + source-independent integrity logic in
|
|
* `morphit-ops upgrade`:
|
|
*
|
|
* - parseReleaseSources: primary-first, dedup, `host` vs `host/owner/repo`.
|
|
* - selectReleaseAssets: tarball + sha256 + optional `.asc`.
|
|
* - decideTrust: the SECURITY-critical matrix — a verified signature
|
|
* trusts any byte source; otherwise the hash must come from the
|
|
* trusted primary; otherwise REFUSE (mirror-only, unsigned).
|
|
* - verifyDetachedSignature: a REAL gpg round-trip with a throwaway
|
|
* key (skipped if gpg isn't installed) — proves the verify path
|
|
* accepts a good signature from a shipped key and rejects a forgery.
|
|
*
|
|
* The live release fetch against git.agorise.net is NOT exercised here
|
|
* (network-restricted); the orchestration around these pieces is what
|
|
* ships, and these are the parts where a mistake would be dangerous.
|
|
*/
|
|
|
|
import { spawnSync } from 'node:child_process';
|
|
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join, dirname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import {
|
|
parseReleaseSources,
|
|
selectReleaseAssets,
|
|
decideTrust,
|
|
verifyDetachedSignature,
|
|
resolveOfflineTarball,
|
|
parseTagFromTarballName,
|
|
findLocalOfflineRelease,
|
|
compareTags,
|
|
stripInheritedNpmOffline
|
|
} from '../src/commands/upgrade.ts';
|
|
|
|
let pass = 0;
|
|
let fail = 0;
|
|
const ok = (m: string) => {
|
|
pass++;
|
|
console.log(` \u2713 ${m}`);
|
|
};
|
|
const bad = (m: string, d = '') => {
|
|
fail++;
|
|
console.log(` \u2717 ${m}`);
|
|
if (d) console.log(` ${d}`);
|
|
};
|
|
const expect = (n: string, c: boolean, d = '') => (c ? ok(n) : bad(n, d));
|
|
|
|
const asset = (name: string) => ({ name, browser_download_url: `https://h/${name}`, size: 1 });
|
|
|
|
// ── parseReleaseSources ─────────────────────────────────────────────
|
|
{
|
|
// The canonical primary gets the 2 built-in mirrors (codeberg.org + gitea.com)
|
|
// gitea.com) with NO env config, so `morphit-ops upgrade` auto-rotates off
|
|
// git.agorise.net the moment it's unreachable.
|
|
const def = parseReleaseSources('git.agorise.net', 'agorise/morphit', undefined);
|
|
expect('sources: canonical primary is first + flagged primary', def[0]!.isPrimary === true && def[0]!.host === 'git.agorise.net');
|
|
expect('sources: built-in codeberg mirror present by default', def.some((s) => s.host === 'codeberg.org' && s.repo === 'agorise/morphit' && !s.isPrimary));
|
|
expect('sources: built-in gitea.com mirror present by default', def.some((s) => s.host === 'gitea.com' && s.repo === 'agorise/morphit' && !s.isPrimary));
|
|
expect('sources: exactly primary + 2 built-ins when no env mirrors', def.length === 3);
|
|
|
|
// A NON-canonical primary (fork/custom) gets NO built-ins — it points its own.
|
|
const fork = parseReleaseSources('git.myfork.net', 'me/morphit', undefined);
|
|
expect('sources: custom primary gets no built-in mirrors', fork.length === 1 && fork[0]!.isPrimary === true);
|
|
|
|
// Env mirrors are ADDED (after primary + built-ins); bare host reuses primary
|
|
// repo, host/owner/repo is parsed, and none are flagged primary.
|
|
const withM = parseReleaseSources('git.agorise.net', 'agorise/morphit', 'mirror.example, other.example/them/repo');
|
|
expect('sources: primary still first', withM[0]!.isPrimary === true && withM[0]!.host === 'git.agorise.net');
|
|
expect('sources: env bare host reuses primary repo', withM.some((s) => s.host === 'mirror.example' && s.repo === 'agorise/morphit' && !s.isPrimary));
|
|
expect('sources: env host/owner/repo parsed', withM.some((s) => s.host === 'other.example' && s.repo === 'them/repo' && !s.isPrimary));
|
|
expect('sources: env mirrors not flagged primary', withM.filter((s) => s.host === 'mirror.example' || s.host === 'other.example').every((s) => !s.isPrimary));
|
|
|
|
// Dedup across primary + built-ins + env (scheme/trailing-slash stripped): a repeat
|
|
// of the primary and of a built-in are dropped; mirror.example appears once.
|
|
const dup = parseReleaseSources('git.agorise.net', 'agorise/morphit', 'git.agorise.net/agorise/morphit, codeberg.org/agorise/morphit, https://mirror.example/, mirror.example');
|
|
expect('sources: dedup primary + dedup built-in + dedup mirror + strip scheme/slash', dup.filter((s) => s.host === 'git.agorise.net').length === 1 && dup.filter((s) => s.host === 'codeberg.org').length === 1 && dup.filter((s) => s.host === 'mirror.example').length === 1);
|
|
}
|
|
|
|
// ── selectReleaseAssets ─────────────────────────────────────────────
|
|
{
|
|
const full = selectReleaseAssets([asset('morphit-v1.tar.gz'), asset('morphit-v1.tar.gz.sha256'), asset('morphit-v1.tar.gz.asc'), asset('notes.txt')]);
|
|
expect('assets: picks tarball+sha+sig', !!full && full.tarball.name.endsWith('.tar.gz') && full.sha.name.endsWith('.sha256') && full.sig?.name.endsWith('.asc') === true);
|
|
|
|
const noSig = selectReleaseAssets([asset('morphit-v1.tar.gz'), asset('morphit-v1.tar.gz.sha256')]);
|
|
expect('assets: sig optional (null when absent)', !!noSig && noSig.sig === null);
|
|
|
|
const noSha = selectReleaseAssets([asset('morphit-v1.tar.gz')]);
|
|
expect('assets: null when sha missing', noSha === null);
|
|
|
|
// cp669 — a 1.10.1+ release ships BOTH the slim tarball AND the -offline
|
|
// bundle (each with its own .sha256/.asc). An online upgrade must pick the
|
|
// SLIM tarball and the SLIM tarball's .sha256 — NOT cross them (that caused a
|
|
// false "SHA-256 mismatch" on a mirror fallback). Order the -offline assets
|
|
// FIRST to prove selection isn't just "first .tar.gz".
|
|
const both = selectReleaseAssets([
|
|
asset('morphit-v1.10.2-offline.tar.gz'),
|
|
asset('morphit-v1.10.2-offline.tar.gz.sha256'),
|
|
asset('morphit-v1.10.2-offline.tar.gz.asc'),
|
|
asset('morphit-v1.10.2.tar.gz'),
|
|
asset('morphit-v1.10.2.tar.gz.sha256'),
|
|
asset('morphit-v1.10.2.tar.gz.asc')
|
|
]);
|
|
expect('assets: prefers the SLIM tarball when -offline is also present', both?.tarball.name === 'morphit-v1.10.2.tar.gz');
|
|
expect('assets: pairs the SLIM tarball with its OWN .sha256 (not the offline one)', both?.sha.name === 'morphit-v1.10.2.tar.gz.sha256');
|
|
expect('assets: pairs the SLIM tarball with its OWN .asc', both?.sig?.name === 'morphit-v1.10.2.tar.gz.asc');
|
|
|
|
// the synthetic --from-file release has ONLY the -offline tarball → must still
|
|
// select it (fallback), paired with ITS matching .sha256.
|
|
const offlineOnly = selectReleaseAssets([
|
|
asset('morphit-v1.10.2-offline.tar.gz'),
|
|
asset('morphit-v1.10.2-offline.tar.gz.sha256')
|
|
]);
|
|
expect('assets: falls back to -offline when it is the only tarball', offlineOnly?.tarball.name === 'morphit-v1.10.2-offline.tar.gz' && offlineOnly?.sha.name === 'morphit-v1.10.2-offline.tar.gz.sha256');
|
|
}
|
|
|
|
// ── decideTrust (security matrix) ───────────────────────────────────
|
|
expect(
|
|
'trust: verified signature trusts ANY source (mirror bytes ok)',
|
|
decideTrust({ bytesFromPrimary: false, sigVerified: true, hashMatched: false, hashFromPrimary: false }).allowed === true
|
|
);
|
|
expect(
|
|
'trust: primary HTTPS + hash match → allowed',
|
|
(() => {
|
|
const t = decideTrust({ bytesFromPrimary: true, sigVerified: false, hashMatched: true, hashFromPrimary: true });
|
|
return t.allowed && t.proof === 'primary-https-hash';
|
|
})()
|
|
);
|
|
expect(
|
|
'trust: mirror bytes + primary-anchored hash → allowed',
|
|
(() => {
|
|
const t = decideTrust({ bytesFromPrimary: false, sigVerified: false, hashMatched: true, hashFromPrimary: true });
|
|
return t.allowed && t.proof === 'primary-anchored-hash';
|
|
})()
|
|
);
|
|
expect(
|
|
'trust: REFUSE when unsigned + no primary anchor (mirror-only)',
|
|
decideTrust({ bytesFromPrimary: false, sigVerified: false, hashMatched: false, hashFromPrimary: false }).allowed === false
|
|
);
|
|
expect(
|
|
'trust: REFUSE even if hash "matched" but NOT from primary (mirror self-checksum)',
|
|
decideTrust({ bytesFromPrimary: false, sigVerified: false, hashMatched: true, hashFromPrimary: false }).allowed === false
|
|
);
|
|
|
|
// ── verifyDetachedSignature: real gpg round-trip (gated) ────────────
|
|
const haveGpg = spawnSync('which', ['gpg'], { stdio: 'pipe' }).status === 0;
|
|
if (!haveGpg) {
|
|
console.log(' \u26a0 gpg not installed — skipping signature round-trip (logic above still covered)');
|
|
} else {
|
|
const work = mkdtempSync(join(tmpdir(), 'morphit-sig-test-'));
|
|
const gnupg = join(work, 'gnupg');
|
|
mkdirSync(gnupg, { recursive: true });
|
|
spawnSync('chmod', ['700', gnupg], { stdio: 'ignore' });
|
|
const env = { ...process.env, GNUPGHOME: gnupg };
|
|
try {
|
|
// 1. Generate a throwaway signing key.
|
|
spawnSync('gpg', ['--homedir', gnupg, '--batch', '--passphrase', '', '--quick-generate-key', 'Morphit Test Signer <test@morphit.invalid>', 'default', 'default', 'never'], { stdio: 'pipe', env, timeout: 30000 });
|
|
// 2. Export its PUBLIC key into a fake install's release-signers dir.
|
|
const installDir = join(work, 'install');
|
|
const signers = join(installDir, '.forgejo', 'release-signers');
|
|
mkdirSync(signers, { recursive: true });
|
|
const pub = spawnSync('gpg', ['--homedir', gnupg, '--armor', '--export', 'test@morphit.invalid'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env });
|
|
writeFileSync(join(signers, 'test-signer.asc'), pub.stdout as string);
|
|
// 3. Create a "tarball" + detached signature.
|
|
const tarball = join(work, 'morphit-vtest.tar.gz');
|
|
writeFileSync(tarball, Buffer.from('pretend tarball bytes'));
|
|
spawnSync('gpg', ['--homedir', gnupg, '--batch', '--yes', '--armor', '--detach-sign', '--output', `${tarball}.asc`, tarball], { stdio: 'pipe', env, timeout: 20000 });
|
|
|
|
// 4. GOOD signature → verifyDetachedSignature returns true.
|
|
const good = verifyDetachedSignature(installDir, tarball, `${tarball}.asc`);
|
|
expect('gpg: valid signature from a shipped key verifies', good === true);
|
|
|
|
// 5. Tamper the tarball → signature must FAIL.
|
|
writeFileSync(tarball, Buffer.from('tampered tarball bytes'));
|
|
const tampered = verifyDetachedSignature(installDir, tarball, `${tarball}.asc`);
|
|
expect('gpg: tampered tarball fails verification', tampered === false);
|
|
|
|
// 6. Signature by a key NOT shipped → must FAIL (empty signers dir).
|
|
const emptyInstall = join(work, 'empty-install');
|
|
mkdirSync(join(emptyInstall, '.forgejo', 'release-signers'), { recursive: true });
|
|
writeFileSync(tarball, Buffer.from('pretend tarball bytes')); // restore original so sig is otherwise valid
|
|
const unknownSigner = verifyDetachedSignature(emptyInstall, tarball, `${tarball}.asc`);
|
|
expect('gpg: signature from a non-shipped key is rejected', unknownSigner === false);
|
|
} finally {
|
|
rmSync(work, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
// ── offline upgrade (--from-file / MORPHIT_UPGRADE_TARBALL, cable unplugged) ──
|
|
// The offline path bypasses ALL network discovery + download and reuses the SAME
|
|
// trust matrix above: with no reachable primary there's no anchored hash, so
|
|
// decideTrust falls to the "sig or bust" cases already asserted — an UNSIGNED
|
|
// offline tarball is the "all false → refused" case, a SIGNED one is the
|
|
// "sigVerified → allowed" case. These checks pin the offline PLUMBING.
|
|
{
|
|
// the offline-unsigned refusal is exactly the all-false decideTrust case
|
|
expect(
|
|
'offline unsigned tarball is refused (no primary hash, no signature)',
|
|
decideTrust({ bytesFromPrimary: false, sigVerified: false, hashMatched: false, hashFromPrimary: false }).allowed === false
|
|
);
|
|
// a signed offline tarball is trusted regardless of byte source (local file)
|
|
expect(
|
|
'offline SIGNED tarball is trusted (gpg proof, any source)',
|
|
decideTrust({ bytesFromPrimary: false, sigVerified: true, hashMatched: false, hashFromPrimary: false }).proof === 'gpg-signature'
|
|
);
|
|
|
|
// parseTagFromTarballName — version parsed from the filename
|
|
expect('parses vX.Y.Z from an -offline tarball name', parseTagFromTarballName('morphit-v1.10.0-offline.tar.gz') === 'v1.10.0');
|
|
expect('parses vX.Y.Z from a plain release tarball name', parseTagFromTarballName('morphit-v1.9.6.tar.gz') === 'v1.9.6');
|
|
expect('returns null when no version is present', parseTagFromTarballName('morphit-latest.tar.gz') === null);
|
|
|
|
// resolveOfflineTarball — null when neither flag nor env is set (online path)
|
|
const savedEnv = process.env.MORPHIT_UPGRADE_TARBALL;
|
|
delete process.env.MORPHIT_UPGRADE_TARBALL;
|
|
expect('resolveOfflineTarball is null on the normal (online) path', resolveOfflineTarball({}) === null);
|
|
|
|
// resolveOfflineTarball — resolves a real local tarball + sibling .asc + tag
|
|
const off = mkdtempSync(join(tmpdir(), 'morphit-offline-'));
|
|
try {
|
|
const tb = join(off, 'morphit-v1.10.0-offline.tar.gz');
|
|
writeFileSync(tb, Buffer.from('pretend tarball'));
|
|
let r = resolveOfflineTarball({ 'from-file': tb });
|
|
expect('resolveOfflineTarball resolves path + tag', r !== null && r.tag === 'v1.10.0' && r.tarballPath === tb);
|
|
expect('resolveOfflineTarball reports no sig when .asc absent', r !== null && r.sigPath === null);
|
|
writeFileSync(`${tb}.asc`, Buffer.from('pretend sig'));
|
|
r = resolveOfflineTarball({ 'from-file': tb });
|
|
expect('resolveOfflineTarball finds a sibling .asc', r !== null && r.sigPath === `${tb}.asc`);
|
|
|
|
// env var is honoured too
|
|
process.env.MORPHIT_UPGRADE_TARBALL = tb;
|
|
expect('MORPHIT_UPGRADE_TARBALL env is honoured', resolveOfflineTarball({}) !== null);
|
|
delete process.env.MORPHIT_UPGRADE_TARBALL;
|
|
|
|
// bad inputs throw (missing file / wrong ext / no version)
|
|
let threw = false;
|
|
try { resolveOfflineTarball({ 'from-file': join(off, 'nope.tar.gz') }); } catch { threw = true; }
|
|
expect('throws on a missing file', threw);
|
|
threw = false;
|
|
try { resolveOfflineTarball({ 'from-file': tb.replace('.tar.gz', '.zip') }); } catch { threw = true; }
|
|
expect('throws on a non-.tar.gz path', threw);
|
|
const noVer = join(off, 'morphit-latest.tar.gz');
|
|
writeFileSync(noVer, Buffer.from('x'));
|
|
threw = false;
|
|
try { resolveOfflineTarball({ 'from-file': noVer }); } catch { threw = true; }
|
|
expect('throws when the filename has no version', threw);
|
|
} finally {
|
|
rmSync(off, { recursive: true, force: true });
|
|
if (savedEnv !== undefined) process.env.MORPHIT_UPGRADE_TARBALL = savedEnv;
|
|
}
|
|
|
|
// static: the offline branch must not download, and the rebuild must skip
|
|
// npm ci on the prebuilt-bundle marker (so it is genuinely cable-unplugged)
|
|
const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../src/commands/upgrade.ts'), 'utf8');
|
|
expect('rebuild skips npm ci when .morphit-bundle-complete is present', /\.morphit-bundle-complete[\s\S]{0,400}?skipping npm ci/.test(src));
|
|
expect('offline branch copies the local tarball instead of downloading', /offline !== null[\s\S]{0,600}?copyFileSync\(offline\.tarballPath/.test(src));
|
|
|
|
// ── drop-dir detection + online→offline fallback (cp667) ──
|
|
// compareTags: newest wins, release beats prerelease of the same X.Y.Z
|
|
expect('compareTags: v1.10.1 newer than v1.10.0', compareTags('v1.10.1', 'v1.10.0') > 0);
|
|
expect('compareTags: release beats its prerelease', compareTags('v1.10.0', 'v1.10.0-beta.1') > 0);
|
|
expect('compareTags: equal tags compare 0', compareTags('v1.10.0', 'v1.10.0') === 0);
|
|
|
|
// findLocalOfflineRelease scans MORPHIT_OFFLINE_RELEASE_DIR, needs a sibling .asc
|
|
const drop = mkdtempSync(join(tmpdir(), 'morphit-drop-'));
|
|
const savedDir = process.env.MORPHIT_OFFLINE_RELEASE_DIR;
|
|
try {
|
|
process.env.MORPHIT_OFFLINE_RELEASE_DIR = drop;
|
|
expect('findLocalOfflineRelease: empty dir → null', findLocalOfflineRelease('/opt/morphit') === null);
|
|
// an UNSIGNED tarball is ignored (no .asc → not trustable offline)
|
|
writeFileSync(join(drop, 'morphit-v1.10.0-offline.tar.gz'), Buffer.from('x'));
|
|
expect('findLocalOfflineRelease: unsigned tarball ignored', findLocalOfflineRelease('/opt/morphit') === null);
|
|
// sign it → now found
|
|
writeFileSync(join(drop, 'morphit-v1.10.0-offline.tar.gz.asc'), Buffer.from('sig'));
|
|
let f = findLocalOfflineRelease('/opt/morphit');
|
|
expect('findLocalOfflineRelease: signed tarball found', f !== null && f.tag === 'v1.10.0');
|
|
// a NEWER signed tarball wins
|
|
writeFileSync(join(drop, 'morphit-v1.10.1-offline.tar.gz'), Buffer.from('x'));
|
|
writeFileSync(join(drop, 'morphit-v1.10.1-offline.tar.gz.asc'), Buffer.from('sig'));
|
|
f = findLocalOfflineRelease('/opt/morphit');
|
|
expect('findLocalOfflineRelease: newest signed tarball wins', f !== null && f.tag === 'v1.10.1');
|
|
} finally {
|
|
rmSync(drop, { recursive: true, force: true });
|
|
if (savedDir !== undefined) process.env.MORPHIT_OFFLINE_RELEASE_DIR = savedDir;
|
|
else delete process.env.MORPHIT_OFFLINE_RELEASE_DIR;
|
|
}
|
|
|
|
// static: runUpgrade falls back to the drop-dir tarball when all sources fail
|
|
expect(
|
|
'runUpgrade falls back to a dropped offline tarball when the network is down',
|
|
/latest === null[\s\S]{0,400}?findLocalOfflineRelease\(installDir\)/.test(src)
|
|
);
|
|
}
|
|
|
|
console.log('');
|
|
// ── cp674 — strip an inherited npm offline flag before spawning child npm ──
|
|
// Regression for the morphitlat ENOTCACHED failure: the ansible launcher runs
|
|
// the CLI via `npm exec --offline`, exporting npm_config_offline=true, which
|
|
// forced the upgrade's `npm ci` cache-only and broke every online upgrade that
|
|
// pulled a dependency not already cached. runUpgrade now strips it.
|
|
{
|
|
const env: Record<string, string | undefined> = {
|
|
npm_config_offline: 'true',
|
|
npm_config_prefer_offline: 'true',
|
|
NPM_CONFIG_OFFLINE: 'true',
|
|
NPM_CONFIG_PREFER_OFFLINE: 'true',
|
|
PATH: '/usr/bin',
|
|
npm_config_cache: '/var/cache/npm'
|
|
};
|
|
const cleared = stripInheritedNpmOffline(env as NodeJS.ProcessEnv);
|
|
expect(
|
|
'cp674: all four offline flag variants are stripped',
|
|
env.npm_config_offline === undefined &&
|
|
env.npm_config_prefer_offline === undefined &&
|
|
env.NPM_CONFIG_OFFLINE === undefined &&
|
|
env.NPM_CONFIG_PREFER_OFFLINE === undefined
|
|
);
|
|
expect('cp674: it reports the keys it cleared', cleared.length === 4);
|
|
expect(
|
|
'cp674: unrelated env (PATH, npm cache) is preserved',
|
|
env.PATH === '/usr/bin' && env.npm_config_cache === '/var/cache/npm'
|
|
);
|
|
}
|
|
{
|
|
const clean: Record<string, string | undefined> = { PATH: '/usr/bin' };
|
|
const cleared = stripInheritedNpmOffline(clean as NodeJS.ProcessEnv);
|
|
expect('cp674: no-op when no offline flag is present', cleared.length === 0 && clean.PATH === '/usr/bin');
|
|
}
|
|
|
|
// ── cp685 — chdir to a stable dir before the rename-swap ──
|
|
// The launcher runs us with cwd inside the install dir; renaming it to the
|
|
// backup leaves a stale cwd, and every shell spawned afterward prints
|
|
// "getcwd: cannot access parent directories" — harmless but trust-eroding.
|
|
// runUpgrade must chdir('/') BEFORE renameSync(installDir, backupDir).
|
|
{
|
|
const src = readFileSync(new URL('../src/commands/upgrade.ts', import.meta.url), 'utf8');
|
|
const chdirIdx = src.indexOf("process.chdir('/')");
|
|
const renameIdx = src.indexOf('renameSync(installDir, backupDir)');
|
|
expect('cp685: upgrade chdirs to a stable dir before the rename-swap', chdirIdx !== -1 && renameIdx !== -1 && chdirIdx < renameIdx);
|
|
}
|
|
|
|
// ── cp686/cp687 — a clean, trust-preserving upgrade output ──
|
|
// cp686: quiet npm's unactionable "deprecated" warnings during the upgrade.
|
|
// cp687: raise the frontend build's chunk-size warning limit for upgrades.
|
|
{
|
|
const src = readFileSync(new URL('../src/commands/upgrade.ts', import.meta.url), 'utf8');
|
|
expect('cp686: upgrade quiets npm to error loglevel', /npm_config_loglevel = 'error'/.test(src));
|
|
expect('cp687: upgrade sets MORPHIT_QUIET_BUILD for the frontend build', /MORPHIT_QUIET_BUILD = '1'/.test(src));
|
|
const vite = readFileSync(new URL('../../web/vite.config.js', import.meta.url), 'utf8');
|
|
expect('cp687: vite raises chunkSizeWarningLimit only under MORPHIT_QUIET_BUILD', /MORPHIT_QUIET_BUILD === '1'\s*\?\s*\d+\s*:\s*500/.test(vite));
|
|
}
|
|
|
|
// ── cp688 — retry served-frontend verify (container just restarted) ──
|
|
{
|
|
const src = readFileSync(new URL('../src/commands/upgrade.ts', import.meta.url), 'utf8');
|
|
expect('cp688: served-frontend verify retries before giving up', /servedVersion === null && attempt < \d+/.test(src) && /resolveServedVersion\(plan, webRoot\)/.test(src));
|
|
}
|
|
|
|
console.log(`${pass} passed, ${fail} failed`);
|
|
if (fail > 0) {
|
|
console.log('\u2717 upgrade-mirror smoke FAILED');
|
|
process.exit(1);
|
|
}
|
|
console.log(`\u2713 all ${pass} upgrade-mirror scenarios passed`);
|