Windows CI / verify (push) Failing after 2m35s
Canonicalize shortcut targets across Windows short and long path forms while preserving exact target validation. Keep Gitea 1.22 push verification parser-compatible and harden CI contracts against skipped or weakened live gates.
318 lines
15 KiB
JavaScript
318 lines
15 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
const { spawnSync } = require('child_process');
|
|
|
|
const root = path.resolve(__dirname, '..');
|
|
const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
const appGuid = '08429788-303d-53b6-a4f9-894401712c7e';
|
|
const shortcutName = packageJson.build.nsis.shortcutName;
|
|
|
|
function run(command, args, options = {}) {
|
|
const result = spawnSync(command, args, {
|
|
...options,
|
|
encoding: 'utf8',
|
|
timeout: 240000,
|
|
windowsHide: true
|
|
});
|
|
if (result.error) throw result.error;
|
|
if (result.status !== 0) {
|
|
throw new Error(`${path.basename(command)} failed: ${JSON.stringify({ status: result.status, signal: result.signal, stdout: result.stdout, stderr: result.stderr })}`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function findUninstaller(installationDirectory) {
|
|
if (!fs.existsSync(installationDirectory)) return '';
|
|
return fs.readdirSync(installationDirectory)
|
|
.filter((name) => /^uninstall.*\.exe$/i.test(name))
|
|
.map((name) => path.join(installationDirectory, name))[0] || '';
|
|
}
|
|
|
|
function createInstallerPhases(smokeRoot, folders) {
|
|
if (!path.win32.isAbsolute(smokeRoot)) throw new Error(`Installer smoke root is not absolute: ${smokeRoot}`);
|
|
for (const name of ['commonDesktop', 'commonPrograms', 'currentDesktop', 'currentPrograms']) {
|
|
if (!path.win32.isAbsolute(folders[name] || '')) throw new Error(`Windows shell folder ${name} is invalid: ${folders[name] || ''}`);
|
|
}
|
|
return [
|
|
{
|
|
desktopShortcut: path.win32.join(folders.currentDesktop, `${shortcutName}.lnk`),
|
|
flag: '/currentuser',
|
|
hive: 'HKCU',
|
|
installationDirectory: path.win32.join(smokeRoot, 'currentuser', 'app'),
|
|
oppositeHive: 'HKLM',
|
|
startMenuShortcut: path.win32.join(folders.currentPrograms, `${shortcutName}.lnk`)
|
|
},
|
|
{
|
|
desktopShortcut: path.win32.join(folders.commonDesktop, `${shortcutName}.lnk`),
|
|
flag: '/allusers',
|
|
hive: 'HKLM',
|
|
installationDirectory: path.win32.join(smokeRoot, 'allusers', 'app'),
|
|
oppositeHive: 'HKCU',
|
|
startMenuShortcut: path.win32.join(folders.commonPrograms, `${shortcutName}.lnk`)
|
|
}
|
|
].map((phase) => ({
|
|
...phase,
|
|
executablePath: path.win32.join(phase.installationDirectory, `${packageJson.build.productName}.exe`),
|
|
iconPath: path.win32.join(phase.installationDirectory, 'resources', 'app-icons', `icon-${packageJson.version}.ico`),
|
|
installArguments: ['/S', phase.flag, `/D=${phase.installationDirectory}`]
|
|
}));
|
|
}
|
|
|
|
function normalizeWindowsPath(candidate) {
|
|
return path.win32.resolve(String(candidate)).replaceAll('/', '\\').toLowerCase();
|
|
}
|
|
|
|
function canonicalWindowsPath(candidate) {
|
|
try {
|
|
return normalizeWindowsPath(fs.realpathSync.native(candidate));
|
|
} catch {
|
|
return normalizeWindowsPath(candidate);
|
|
}
|
|
}
|
|
|
|
function assertPathInside(targetPath, parentPath) {
|
|
const relative = path.win32.relative(path.win32.resolve(parentPath), path.win32.resolve(targetPath));
|
|
if (!relative || relative.startsWith('..\\') || relative === '..' || path.win32.isAbsolute(relative)) {
|
|
throw new Error(`Refusing recursive cleanup outside its parent: ${JSON.stringify({ targetPath, parentPath })}`);
|
|
}
|
|
}
|
|
|
|
function assertFile(filePath, label) {
|
|
let isFile = false;
|
|
try {
|
|
isFile = fs.statSync(filePath).isFile();
|
|
} catch {}
|
|
if (!isFile) throw new Error(`${label} is missing: ${filePath}`);
|
|
}
|
|
|
|
function assertShortcutDetails(details, { expectedIcon, expectedTarget, pathExists = fs.existsSync }) {
|
|
const targetPath = String(details.targetPath || '');
|
|
const iconPath = String(details.iconLocation || '').replace(/,\s*-?\d+$/, '');
|
|
if (canonicalWindowsPath(targetPath) !== canonicalWindowsPath(expectedTarget)) {
|
|
throw new Error(`Shortcut target mismatch: ${JSON.stringify({ actual: targetPath, expected: expectedTarget })}`);
|
|
}
|
|
if (canonicalWindowsPath(iconPath) !== canonicalWindowsPath(expectedIcon)) {
|
|
throw new Error(`Shortcut icon mismatch: ${JSON.stringify({ actual: iconPath, expected: expectedIcon })}`);
|
|
}
|
|
if (!pathExists(targetPath)) throw new Error(`Shortcut target is missing: ${targetPath}`);
|
|
if (!pathExists(iconPath)) throw new Error(`Shortcut icon is missing: ${iconPath}`);
|
|
}
|
|
|
|
function registryKeys(hive) {
|
|
return {
|
|
install: `${hive}\\Software\\${appGuid}`,
|
|
uninstall: `${hive}\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${appGuid}`
|
|
};
|
|
}
|
|
|
|
function assertInstalledRegistration(phase, { expectedUninstallerPath, keyExists, readRegistryValue }) {
|
|
const selectedKeys = registryKeys(phase.hive);
|
|
const oppositeKeys = registryKeys(phase.oppositeHive);
|
|
if (!keyExists(selectedKeys.install) || !keyExists(selectedKeys.uninstall)) {
|
|
throw new Error(`Installer did not register the ${phase.flag} installation in ${phase.hive}`);
|
|
}
|
|
if (keyExists(oppositeKeys.install) || keyExists(oppositeKeys.uninstall)) {
|
|
throw new Error(`Installer left registration in the opposite installation scope ${phase.oppositeHive}`);
|
|
}
|
|
|
|
const registeredLocation = readRegistryValue(selectedKeys.install, 'InstallLocation');
|
|
if (normalizeWindowsPath(registeredLocation) !== normalizeWindowsPath(phase.installationDirectory)) {
|
|
throw new Error(`Registered install location mismatch: ${JSON.stringify({ actual: registeredLocation, expected: phase.installationDirectory })}`);
|
|
}
|
|
|
|
const uninstallString = String(readRegistryValue(selectedKeys.uninstall, 'UninstallString') || '');
|
|
const quietUninstallString = String(readRegistryValue(selectedKeys.uninstall, 'QuietUninstallString') || '');
|
|
const expectedUninstallString = `"${expectedUninstallerPath}" ${phase.flag}`;
|
|
const expectedQuietUninstallString = `${expectedUninstallString} /S`;
|
|
if (uninstallString.toLowerCase() !== expectedUninstallString.toLowerCase() || quietUninstallString.toLowerCase() !== expectedQuietUninstallString.toLowerCase()) {
|
|
throw new Error(`Registered uninstall command mismatch: ${JSON.stringify({ actual: { quietUninstallString, uninstallString }, expected: { quietUninstallString: expectedQuietUninstallString, uninstallString: expectedUninstallString } })}`);
|
|
}
|
|
}
|
|
|
|
function assertInstallerSurfaceClean(phases, { keyExists, pathExists = fs.existsSync }) {
|
|
const registrySurface = [...new Set(phases.flatMap((phase) => Object.values(registryKeys(phase.hive))))];
|
|
const shortcutSurface = [...new Set(phases.flatMap((phase) => [phase.startMenuShortcut, phase.desktopShortcut]))];
|
|
const existing = [
|
|
...registrySurface.filter(keyExists),
|
|
...shortcutSurface.filter(pathExists)
|
|
];
|
|
if (existing.length > 0) {
|
|
throw new Error(`Installer smoke requires a clean registry and shortcut surface: ${existing.join(', ')}`);
|
|
}
|
|
}
|
|
|
|
function registryKeyExists(key) {
|
|
const result = spawnSync('reg.exe', ['query', key], { encoding: 'utf8', timeout: 30000, windowsHide: true });
|
|
if (result.error) throw result.error;
|
|
if (result.status === 0) return true;
|
|
if (result.status === 1) return false;
|
|
throw new Error(`Registry query failed: ${JSON.stringify({ key, status: result.status, stdout: result.stdout, stderr: result.stderr })}`);
|
|
}
|
|
|
|
function readRegistryValue(key, name) {
|
|
const result = spawnSync('reg.exe', ['query', key, '/v', name], { encoding: 'utf8', timeout: 30000, windowsHide: true });
|
|
if (result.error) throw result.error;
|
|
if (result.status === 1) return null;
|
|
if (result.status !== 0) {
|
|
throw new Error(`Registry value query failed: ${JSON.stringify({ key, name, status: result.status, stdout: result.stdout, stderr: result.stderr })}`);
|
|
}
|
|
const valueMatch = result.stdout
|
|
.split(/\r?\n/)
|
|
.map((line) => line.match(/^\s*(\S+)\s+REG_\w+\s+(.*)$/i))
|
|
.find((match) => match?.[1]?.toLowerCase() === name.toLowerCase());
|
|
return valueMatch?.[2]?.trim() ?? null;
|
|
}
|
|
|
|
function runPowerShell(script, environment = {}) {
|
|
const result = run('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script], {
|
|
env: { ...process.env, ...environment }
|
|
});
|
|
return result.stdout.trim().replace(/^\uFEFF/, '');
|
|
}
|
|
|
|
function readShellFolders() {
|
|
return JSON.parse(runPowerShell("[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false); [ordered]@{ currentPrograms = [Environment]::GetFolderPath([Environment+SpecialFolder]::Programs); commonPrograms = [Environment]::GetFolderPath([Environment+SpecialFolder]::CommonPrograms); currentDesktop = [Environment]::GetFolderPath([Environment+SpecialFolder]::DesktopDirectory); commonDesktop = [Environment]::GetFolderPath([Environment+SpecialFolder]::CommonDesktopDirectory) } | ConvertTo-Json -Compress"));
|
|
}
|
|
|
|
function readShortcutDetails(shortcutPath) {
|
|
const script = "[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false); $shortcut = (New-Object -ComObject WScript.Shell).CreateShortcut($env:TVM_INSTALLER_SMOKE_SHORTCUT); [ordered]@{ targetPath = $shortcut.TargetPath; iconLocation = $shortcut.IconLocation } | ConvertTo-Json -Compress";
|
|
return JSON.parse(runPowerShell(script, { TVM_INSTALLER_SMOKE_SHORTCUT: shortcutPath }));
|
|
}
|
|
|
|
function assertAdministrator() {
|
|
runPowerShell("$identity = [Security.Principal.WindowsIdentity]::GetCurrent(); $principal = [Security.Principal.WindowsPrincipal]::new($identity); if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { exit 1 }");
|
|
}
|
|
|
|
function assertCleanInstallerSmokeSurface(phases) {
|
|
assertInstallerSurfaceClean(phases, { keyExists: registryKeyExists });
|
|
}
|
|
|
|
function seedOrphanedRegistrations(phase, smokeRoot) {
|
|
for (const hive of ['HKCU', 'HKLM']) {
|
|
const keys = registryKeys(hive);
|
|
const orphanedLocation = path.win32.join(smokeRoot, 'orphaned', phase.flag.slice(1), hive, 'app');
|
|
const orphanedUninstaller = path.win32.join(orphanedLocation, `Uninstall ${packageJson.build.productName}.exe`);
|
|
run('reg.exe', ['add', keys.install, '/v', 'InstallLocation', '/t', 'REG_SZ', '/d', orphanedLocation, '/f']);
|
|
run('reg.exe', ['add', keys.uninstall, '/v', 'UninstallString', '/t', 'REG_SZ', '/d', `"${orphanedUninstaller}" ${phase.flag}`, '/f']);
|
|
}
|
|
}
|
|
|
|
function cleanupInstallerSurface(phases) {
|
|
const keys = [...new Set(phases.flatMap((phase) => Object.values(registryKeys(phase.hive))))];
|
|
for (const key of keys) {
|
|
spawnSync('reg.exe', ['delete', key, '/f'], { encoding: 'utf8', timeout: 30000, windowsHide: true });
|
|
}
|
|
const shortcuts = [...new Set(phases.flatMap((phase) => [phase.startMenuShortcut, phase.desktopShortcut]))];
|
|
for (const shortcut of shortcuts) fs.rmSync(shortcut, { force: true });
|
|
}
|
|
|
|
function verifyInstalledPhase(phase) {
|
|
assertFile(phase.executablePath, 'Installed executable');
|
|
assertFile(phase.iconPath, 'Installed shortcut icon');
|
|
assertFile(phase.startMenuShortcut, 'Start Menu shortcut');
|
|
assertFile(phase.desktopShortcut, 'Desktop shortcut');
|
|
const uninstallerPath = findUninstaller(phase.installationDirectory);
|
|
if (!uninstallerPath) throw new Error(`Installed uninstaller is missing: ${phase.installationDirectory}`);
|
|
assertInstalledRegistration(phase, { expectedUninstallerPath: uninstallerPath, keyExists: registryKeyExists, readRegistryValue });
|
|
for (const shortcutPath of [phase.startMenuShortcut, phase.desktopShortcut]) {
|
|
assertShortcutDetails(readShortcutDetails(shortcutPath), {
|
|
expectedIcon: phase.iconPath,
|
|
expectedTarget: phase.executablePath
|
|
});
|
|
}
|
|
return uninstallerPath;
|
|
}
|
|
|
|
async function waitForPathRemoval(targetPath, timeoutMs = 10000) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (fs.existsSync(targetPath)) {
|
|
if (Date.now() >= deadline) return false;
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function assertHostedWindowsCi(environment = process.env, platform = process.platform) {
|
|
const serverUrl = String(environment.GITHUB_SERVER_URL || '').replace(/\/+$/, '').toLowerCase();
|
|
const isGitHubActions = environment.GITHUB_ACTIONS === 'true' && environment.GITEA_ACTIONS !== 'true' && environment.RUNNER_ENVIRONMENT === 'github-hosted' && serverUrl === 'https://github.com';
|
|
const isGiteaActions = environment.GITEA_ACTIONS === 'true' && serverUrl === 'https://git.24-music.de';
|
|
if (platform !== 'win32' || environment.CI !== 'true' || environment.RUNNER_OS !== 'Windows' || !environment.RUNNER_TEMP || !environment.GITHUB_RUN_ID || (!isGitHubActions && !isGiteaActions)) {
|
|
throw new Error('Real installer smoke is restricted to an approved Windows Actions runner');
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
assertHostedWindowsCi();
|
|
const installerPath = path.join(root, 'release', `Twitch-VOD-Manager-Setup-${packageJson.version}.exe`);
|
|
assertFile(installerPath, 'Installer');
|
|
assertAdministrator();
|
|
|
|
const runnerTemp = process.env.RUNNER_TEMP;
|
|
if (!runnerTemp || !path.win32.isAbsolute(runnerTemp) || !fs.statSync(runnerTemp).isDirectory()) {
|
|
throw new Error(`Hosted runner temp directory is invalid: ${runnerTemp || ''}`);
|
|
}
|
|
const smokeRoot = fs.mkdtempSync(path.join(runnerTemp, 'tvm-installer-'));
|
|
let phases = [];
|
|
const results = [];
|
|
let ownsSurface = false;
|
|
|
|
try {
|
|
assertPathInside(smokeRoot, runnerTemp);
|
|
phases = createInstallerPhases(smokeRoot, readShellFolders());
|
|
assertCleanInstallerSmokeSurface(phases);
|
|
ownsSurface = true;
|
|
for (const phase of phases) {
|
|
seedOrphanedRegistrations(phase, smokeRoot);
|
|
run(installerPath, phase.installArguments, { cwd: smokeRoot });
|
|
const uninstallerPath = verifyInstalledPhase(phase);
|
|
run(process.execPath, [path.join(__dirname, 'smoke-test-packaged-launch.js')], {
|
|
cwd: root,
|
|
env: { ...process.env, PACKAGED_APP_PATH: phase.executablePath }
|
|
});
|
|
run(uninstallerPath, [phase.flag, '/S'], { cwd: smokeRoot });
|
|
if (!await waitForPathRemoval(phase.installationDirectory, 30000)) {
|
|
throw new Error(`Silent uninstall left the installation directory behind: ${phase.installationDirectory}`);
|
|
}
|
|
assertCleanInstallerSmokeSurface(phases);
|
|
results.push({
|
|
flag: phase.flag,
|
|
hive: phase.hive,
|
|
iconPath: phase.iconPath,
|
|
installationDirectory: phase.installationDirectory,
|
|
startMenuShortcut: phase.startMenuShortcut
|
|
});
|
|
}
|
|
console.log(JSON.stringify({ failures: [], installerPath, results }, null, 2));
|
|
} finally {
|
|
if (ownsSurface) {
|
|
for (const phase of [...phases].reverse()) {
|
|
const uninstallerPath = findUninstaller(phase.installationDirectory);
|
|
if (uninstallerPath) {
|
|
spawnSync(uninstallerPath, [phase.flag, '/S'], { cwd: smokeRoot, timeout: 240000, windowsHide: true, stdio: 'ignore' });
|
|
}
|
|
}
|
|
cleanupInstallerSurface(phases);
|
|
}
|
|
assertPathInside(smokeRoot, runnerTemp);
|
|
await fs.promises.rm(smokeRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 250 });
|
|
}
|
|
}
|
|
|
|
if (require.main === module) {
|
|
main().catch((error) => {
|
|
console.error(error instanceof Error ? error.message : String(error));
|
|
process.exitCode = 1;
|
|
});
|
|
}
|
|
|
|
module.exports = {
|
|
assertHostedWindowsCi,
|
|
assertInstalledRegistration,
|
|
assertInstallerSurfaceClean,
|
|
assertPathInside,
|
|
assertShortcutDetails,
|
|
createInstallerPhases,
|
|
readShortcutDetails,
|
|
registryKeys
|
|
};
|