mirror of
https://github.com/nimbold/Firelink.git
synced 2026-09-02 14:07:57 +00:00
fix(release): bound engine provisioning and Linux package setup
- abort locked engine downloads and extraction on process interruption - recover only dead-PID staging trees while preserving active and legacy trees - normalize Ubuntu mirrors and bound apt in release jobs - cover cancellation, orphan cleanup, and workflow contracts with focused tests
This commit is contained in:
@@ -63,8 +63,27 @@ jobs:
|
|||||||
- name: Install Linux dependencies
|
- name: Install Linux dependencies
|
||||||
if: runner.os == 'Linux'
|
if: runner.os == 'Linux'
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
# Hosted Ubuntu images can expose an unreachable Azure mirror in
|
||||||
sudo apt-get install -y \
|
# their apt sources while the public Ubuntu archive is reachable.
|
||||||
|
# Keep release jobs bounded and use the same source normalization as
|
||||||
|
# the native CI path so a runner-local mirror cannot hang packaging.
|
||||||
|
sudo find /etc/apt -type f \
|
||||||
|
-exec sed -i \
|
||||||
|
-e 's#http://azure\.archive\.ubuntu\.com#https://archive.ubuntu.com#g' \
|
||||||
|
-e 's#https://azure\.archive\.ubuntu\.com#https://archive.ubuntu.com#g' \
|
||||||
|
{} +
|
||||||
|
sudo env DEBIAN_FRONTEND=noninteractive timeout --foreground --signal=TERM --kill-after=30s 10m apt-get \
|
||||||
|
-o Acquire::Retries=3 \
|
||||||
|
-o Acquire::http::Timeout=30 \
|
||||||
|
-o Acquire::https::Timeout=30 \
|
||||||
|
-o DPkg::Lock::Timeout=60 \
|
||||||
|
update
|
||||||
|
sudo env DEBIAN_FRONTEND=noninteractive timeout --foreground --signal=TERM --kill-after=30s 10m apt-get \
|
||||||
|
-o Acquire::Retries=3 \
|
||||||
|
-o Acquire::http::Timeout=30 \
|
||||||
|
-o Acquire::https::Timeout=30 \
|
||||||
|
-o DPkg::Lock::Timeout=60 \
|
||||||
|
install -y \
|
||||||
libwebkit2gtk-4.1-dev \
|
libwebkit2gtk-4.1-dev \
|
||||||
libappindicator3-dev \
|
libappindicator3-dev \
|
||||||
librsvg2-dev \
|
librsvg2-dev \
|
||||||
|
|||||||
@@ -45,6 +45,40 @@ function createDownloadTimeout(idleTimeoutMs) {
|
|||||||
return { signal: controller.signal, refresh, dispose };
|
return { signal: controller.signal, refresh, dispose };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function abortReason(signal) {
|
||||||
|
if (signal?.reason instanceof Error) return signal.reason;
|
||||||
|
return new Error('Engine archive download aborted');
|
||||||
|
}
|
||||||
|
|
||||||
|
function throwIfAborted(signal) {
|
||||||
|
if (signal?.aborted) throw abortReason(signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
function combineAbortSignals(signals) {
|
||||||
|
const activeSignals = signals.filter(Boolean);
|
||||||
|
const controller = new AbortController();
|
||||||
|
const listeners = [];
|
||||||
|
const abort = signal => {
|
||||||
|
if (!controller.signal.aborted) controller.abort(abortReason(signal));
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const signal of activeSignals) {
|
||||||
|
const listener = () => abort(signal);
|
||||||
|
listeners.push([signal, listener]);
|
||||||
|
if (signal.aborted) abort(signal);
|
||||||
|
else signal.addEventListener('abort', listener, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
signal: controller.signal,
|
||||||
|
dispose() {
|
||||||
|
for (const [signal, listener] of listeners) {
|
||||||
|
signal.removeEventListener('abort', listener);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function archiveSize(archive) {
|
function archiveSize(archive) {
|
||||||
try {
|
try {
|
||||||
return fs.statSync(archive).size;
|
return fs.statSync(archive).size;
|
||||||
@@ -60,8 +94,26 @@ function checksumMismatchError(name, expected, actual) {
|
|||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sleep(milliseconds) {
|
function sleep(milliseconds, signal) {
|
||||||
return new Promise(resolve => setTimeout(resolve, milliseconds));
|
if (!signal) return new Promise(resolve => setTimeout(resolve, milliseconds));
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let timer;
|
||||||
|
const cleanup = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
signal.removeEventListener('abort', onAbort);
|
||||||
|
};
|
||||||
|
const finish = () => {
|
||||||
|
cleanup();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
const onAbort = () => {
|
||||||
|
cleanup();
|
||||||
|
reject(abortReason(signal));
|
||||||
|
};
|
||||||
|
timer = setTimeout(finish, milliseconds);
|
||||||
|
signal.addEventListener('abort', onAbort, { once: true });
|
||||||
|
if (signal.aborted) onAbort();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resetArchive(archive) {
|
async function resetArchive(archive) {
|
||||||
@@ -92,20 +144,23 @@ export async function downloadEngineArchive({
|
|||||||
attempts = DEFAULT_ATTEMPTS,
|
attempts = DEFAULT_ATTEMPTS,
|
||||||
idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS,
|
idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS,
|
||||||
retryDelaysMs = DEFAULT_RETRY_DELAYS_MS,
|
retryDelaysMs = DEFAULT_RETRY_DELAYS_MS,
|
||||||
|
signal,
|
||||||
}) {
|
}) {
|
||||||
let lastError;
|
let lastError;
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||||
|
throwIfAborted(signal);
|
||||||
const partialSize = archiveSize(archive);
|
const partialSize = archiveSize(archive);
|
||||||
if (partialSize > 0 && sha256(archive) === expectedSha256) return archive;
|
if (partialSize > 0 && sha256(archive) === expectedSha256) return archive;
|
||||||
const downloadTimeout = createDownloadTimeout(idleTimeoutMs);
|
const downloadTimeout = createDownloadTimeout(idleTimeoutMs);
|
||||||
|
const requestSignal = combineAbortSignals([signal, downloadTimeout.signal]);
|
||||||
let resetForRetry = false;
|
let resetForRetry = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
headers: partialSize > 0 ? { Range: `bytes=${partialSize}-` } : undefined,
|
headers: partialSize > 0 ? { Range: `bytes=${partialSize}-` } : undefined,
|
||||||
redirect: 'follow',
|
redirect: 'follow',
|
||||||
signal: downloadTimeout.signal,
|
signal: requestSignal.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 416 && partialSize > 0) {
|
if (response.status === 416 && partialSize > 0) {
|
||||||
@@ -143,9 +198,10 @@ export async function downloadEngineArchive({
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
fs.createWriteStream(archive, { flags: append ? 'a' : 'w' }),
|
fs.createWriteStream(archive, { flags: append ? 'a' : 'w' }),
|
||||||
{ signal: downloadTimeout.signal },
|
{ signal: requestSignal.signal },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
throwIfAborted(signal);
|
||||||
const finalSize = archiveSize(archive);
|
const finalSize = archiveSize(archive);
|
||||||
const expectedFinalSize = contentRange?.total
|
const expectedFinalSize = contentRange?.total
|
||||||
?? (expectedResponseLength === undefined
|
?? (expectedResponseLength === undefined
|
||||||
@@ -163,6 +219,7 @@ export async function downloadEngineArchive({
|
|||||||
resetForRetry = true;
|
resetForRetry = true;
|
||||||
throw checksumMismatchError(name, expectedSha256, actual);
|
throw checksumMismatchError(name, expectedSha256, actual);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (signal?.aborted) throw abortReason(signal);
|
||||||
lastError = error;
|
lastError = error;
|
||||||
if (resetForRetry || error?.code === 'ARCHIVE_CHECKSUM_MISMATCH') {
|
if (resetForRetry || error?.code === 'ARCHIVE_CHECKSUM_MISMATCH') {
|
||||||
await resetArchive(archive);
|
await resetArchive(archive);
|
||||||
@@ -175,8 +232,9 @@ export async function downloadEngineArchive({
|
|||||||
{ cause: error },
|
{ cause: error },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await new Promise(resolve => setTimeout(resolve, retryDelaysMs[attempt - 1] ?? 0));
|
await sleep(retryDelaysMs[attempt - 1] ?? 0, signal);
|
||||||
} finally {
|
} finally {
|
||||||
|
requestSignal.dispose();
|
||||||
downloadTimeout.dispose();
|
downloadTimeout.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -180,3 +180,48 @@ test('restarts from zero after an unsatisfiable retained range', async () => {
|
|||||||
fs.rmSync(directory, { recursive: true, force: true });
|
fs.rmSync(directory, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('propagates external cancellation without retrying an in-flight archive', async () => {
|
||||||
|
const { directory, archive } = makeArchivePath();
|
||||||
|
const abortController = new AbortController();
|
||||||
|
let calls = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await withMockFetch(async (_url, options) => {
|
||||||
|
calls += 1;
|
||||||
|
assert.equal(options.signal.aborted, false);
|
||||||
|
setTimeout(() => {
|
||||||
|
abortController.abort(new Error('provisioning interrupted'));
|
||||||
|
}, 10);
|
||||||
|
return new Response(new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(Buffer.from('partial archive bytes'));
|
||||||
|
},
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Length': '100' },
|
||||||
|
});
|
||||||
|
}, async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
downloadEngineArchive({
|
||||||
|
name: 'test',
|
||||||
|
url: 'https://example.test/engine.zip',
|
||||||
|
archive,
|
||||||
|
expectedSha256: digest(Buffer.from('never completed')),
|
||||||
|
attempts: 3,
|
||||||
|
retryDelaysMs: [500, 500],
|
||||||
|
signal: abortController.signal,
|
||||||
|
}),
|
||||||
|
error => {
|
||||||
|
assert.match(error.message, /provisioning interrupted/);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
assert.ok(fs.statSync(archive).size > 0);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -50,6 +50,33 @@ async function removeOrphanedPreviousPayloads(destination) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function provisioningTemporaryPrefix(target) {
|
||||||
|
return `.firelink-engines-${target}-`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function provisioningTemporaryOwner(target, candidate) {
|
||||||
|
const suffix = path.basename(candidate).slice(provisioningTemporaryPrefix(target).length);
|
||||||
|
const separator = suffix.indexOf('-');
|
||||||
|
const pid = separator >= 0 ? suffix.slice(0, separator) : suffix;
|
||||||
|
return /^\d+$/.test(pid) ? Number(pid) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes only staging directories created by a provisioner whose PID is no
|
||||||
|
* longer alive. Legacy directories without an owner PID remain untouched so
|
||||||
|
* a concurrent or ambiguous provisioner can never lose its staging tree.
|
||||||
|
*/
|
||||||
|
export async function removeOrphanedProvisioningDirectories(destinationParent, target) {
|
||||||
|
const prefix = provisioningTemporaryPrefix(target);
|
||||||
|
for (const entry of fs.readdirSync(destinationParent, { withFileTypes: true })) {
|
||||||
|
if (!entry.isDirectory() || !entry.name.startsWith(prefix)) continue;
|
||||||
|
const candidate = path.join(destinationParent, entry.name);
|
||||||
|
const owner = provisioningTemporaryOwner(target, candidate);
|
||||||
|
if (owner === null || owner === process.pid || isProcessAlive(owner)) continue;
|
||||||
|
await removePathWithRetry(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restores the only previous payload left by a process that died after moving
|
* Restores the only previous payload left by a process that died after moving
|
||||||
* the destination aside but before publishing its replacement. Multiple
|
* the destination aside but before publishing its replacement. Multiple
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import test from 'node:test';
|
|||||||
import {
|
import {
|
||||||
promoteDirectory,
|
promoteDirectory,
|
||||||
recoverInterruptedPromotion,
|
recoverInterruptedPromotion,
|
||||||
|
removeOrphanedProvisioningDirectories,
|
||||||
removePathWithRetry,
|
removePathWithRetry,
|
||||||
} from './engine-payload-promotion.js';
|
} from './engine-payload-promotion.js';
|
||||||
|
|
||||||
@@ -126,3 +127,24 @@ test('removes orphaned backups from dead provisioners after a successful publish
|
|||||||
fs.rmSync(root, { recursive: true, force: true });
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('removes only provisioning staging owned by a dead PID', async () => {
|
||||||
|
const root = temporaryDirectory();
|
||||||
|
try {
|
||||||
|
const target = 'x86_64-unknown-linux-gnu';
|
||||||
|
const orphaned = path.join(root, `.firelink-engines-${target}-999999999-dead`);
|
||||||
|
const live = path.join(root, `.firelink-engines-${target}-${process.pid}-live`);
|
||||||
|
const legacy = path.join(root, `.firelink-engines-${target}-legacy`);
|
||||||
|
fs.mkdirSync(orphaned, { recursive: true });
|
||||||
|
fs.mkdirSync(live, { recursive: true });
|
||||||
|
fs.mkdirSync(legacy, { recursive: true });
|
||||||
|
|
||||||
|
await removeOrphanedProvisioningDirectories(root, target);
|
||||||
|
|
||||||
|
assert.equal(fs.existsSync(orphaned), false);
|
||||||
|
assert.equal(fs.existsSync(live), true);
|
||||||
|
assert.equal(fs.existsSync(legacy), true);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { execFileSync } from 'node:child_process';
|
import { execFile } from 'node:child_process';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
import { collectRegularFiles, sha256 } from './engine-payload-integrity.js';
|
import { collectRegularFiles, sha256 } from './engine-payload-integrity.js';
|
||||||
import { downloadEngineArchive } from './engine-download.js';
|
import { downloadEngineArchive } from './engine-download.js';
|
||||||
import {
|
import {
|
||||||
promoteDirectory,
|
promoteDirectory,
|
||||||
recoverInterruptedPromotion,
|
recoverInterruptedPromotion,
|
||||||
|
removeOrphanedProvisioningDirectories,
|
||||||
removePathWithRetry,
|
removePathWithRetry,
|
||||||
} from './engine-payload-promotion.js';
|
} from './engine-payload-promotion.js';
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const repoRoot = path.resolve(__dirname, '..');
|
const repoRoot = path.resolve(__dirname, '..');
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
const sourceLock = JSON.parse(
|
const sourceLock = JSON.parse(
|
||||||
fs.readFileSync(path.join(repoRoot, 'engine-sources.lock.json'), 'utf8')
|
fs.readFileSync(path.join(repoRoot, 'engine-sources.lock.json'), 'utf8')
|
||||||
);
|
);
|
||||||
@@ -37,17 +40,34 @@ if (!targetSources) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const destination = path.join(repoRoot, 'src-tauri', 'provisioned-engines', target);
|
const destination = path.join(repoRoot, 'src-tauri', 'provisioned-engines', target);
|
||||||
const destinationParent = path.dirname(destination);
|
|
||||||
fs.mkdirSync(destinationParent, { recursive: true });
|
|
||||||
recoverInterruptedPromotion(destination);
|
|
||||||
// Keep staging on the destination filesystem so the final rename is atomic.
|
|
||||||
const temporary = fs.mkdtempSync(path.join(destinationParent, `.firelink-engines-${target}-`));
|
|
||||||
const payloadDestination = path.join(temporary, 'payload');
|
|
||||||
fs.mkdirSync(payloadDestination, { recursive: true });
|
|
||||||
const isWindows = target.includes('windows');
|
const isWindows = target.includes('windows');
|
||||||
const executableSuffix = isWindows ? '.exe' : '';
|
const executableSuffix = isWindows ? '.exe' : '';
|
||||||
|
const provisioningAbortController = new AbortController();
|
||||||
|
const signalNames = process.platform === 'win32'
|
||||||
|
? ['SIGINT', 'SIGTERM']
|
||||||
|
: ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
||||||
|
const signalHandlers = new Map();
|
||||||
|
for (const signalName of signalNames) {
|
||||||
|
const handler = () => {
|
||||||
|
if (!provisioningAbortController.signal.aborted) {
|
||||||
|
provisioningAbortController.abort(new Error(`Engine provisioning interrupted by ${signalName}`));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
signalHandlers.set(signalName, handler);
|
||||||
|
process.on(signalName, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
let temporary;
|
||||||
|
let payloadDestination;
|
||||||
|
|
||||||
|
function throwIfProvisioningAborted() {
|
||||||
|
if (provisioningAbortController.signal.aborted) {
|
||||||
|
throw provisioningAbortController.signal.reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function download(name, source) {
|
async function download(name, source) {
|
||||||
|
throwIfProvisioningAborted();
|
||||||
const sourcePath = new URL(source.url).pathname;
|
const sourcePath = new URL(source.url).pathname;
|
||||||
const archive = path.join(
|
const archive = path.join(
|
||||||
temporary,
|
temporary,
|
||||||
@@ -58,14 +78,23 @@ async function download(name, source) {
|
|||||||
url: source.url,
|
url: source.url,
|
||||||
archive,
|
archive,
|
||||||
expectedSha256: source.sha256,
|
expectedSha256: source.sha256,
|
||||||
|
signal: provisioningAbortController.signal,
|
||||||
});
|
});
|
||||||
|
throwIfProvisioningAborted();
|
||||||
const extracted = path.join(temporary, `${name}-extracted`);
|
const extracted = path.join(temporary, `${name}-extracted`);
|
||||||
fs.mkdirSync(extracted);
|
fs.mkdirSync(extracted);
|
||||||
if (archive.endsWith('.zip') && process.platform !== 'win32') {
|
if (archive.endsWith('.zip') && process.platform !== 'win32') {
|
||||||
execFileSync('unzip', ['-q', archive, '-d', extracted], { stdio: 'inherit' });
|
await execFileAsync('unzip', ['-q', archive, '-d', extracted], {
|
||||||
|
stdio: 'inherit',
|
||||||
|
signal: provisioningAbortController.signal,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
execFileSync('tar', ['-xf', archive, '-C', extracted], { stdio: 'inherit' });
|
await execFileAsync('tar', ['-xf', archive, '-C', extracted], {
|
||||||
|
stdio: 'inherit',
|
||||||
|
signal: provisioningAbortController.signal,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
throwIfProvisioningAborted();
|
||||||
return extracted;
|
return extracted;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +152,18 @@ function writePayloadManifest() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const destinationParent = path.dirname(destination);
|
||||||
|
fs.mkdirSync(destinationParent, { recursive: true });
|
||||||
|
recoverInterruptedPromotion(destination);
|
||||||
|
await removeOrphanedProvisioningDirectories(destinationParent, target);
|
||||||
|
throwIfProvisioningAborted();
|
||||||
|
// Keep staging on the destination filesystem so the final rename is atomic.
|
||||||
|
temporary = fs.mkdtempSync(
|
||||||
|
path.join(destinationParent, `.firelink-engines-${target}-${process.pid}-`)
|
||||||
|
);
|
||||||
|
payloadDestination = path.join(temporary, 'payload');
|
||||||
|
fs.mkdirSync(payloadDestination, { recursive: true });
|
||||||
|
|
||||||
const ytdlp = await download('yt-dlp', targetSources['yt-dlp']);
|
const ytdlp = await download('yt-dlp', targetSources['yt-dlp']);
|
||||||
copyExecutable(
|
copyExecutable(
|
||||||
findFile(ytdlp, isWindows ? ['yt-dlp.exe'] : ['yt-dlp_linux']),
|
findFile(ytdlp, isWindows ? ['yt-dlp.exe'] : ['yt-dlp_linux']),
|
||||||
@@ -143,8 +184,12 @@ try {
|
|||||||
copyExecutable(findFile(aria2, isWindows ? ['aria2c.exe'] : ['aria2c']), 'aria2c');
|
copyExecutable(findFile(aria2, isWindows ? ['aria2c.exe'] : ['aria2c']), 'aria2c');
|
||||||
|
|
||||||
writePayloadManifest();
|
writePayloadManifest();
|
||||||
|
throwIfProvisioningAborted();
|
||||||
await promoteDirectory(payloadDestination, destination);
|
await promoteDirectory(payloadDestination, destination);
|
||||||
console.log(`Provisioned locked engine payload at ${destination}`);
|
console.log(`Provisioned locked engine payload at ${destination}`);
|
||||||
} finally {
|
} finally {
|
||||||
await removePathWithRetry(temporary);
|
if (temporary) await removePathWithRetry(temporary);
|
||||||
|
for (const [signalName, handler] of signalHandlers) {
|
||||||
|
process.removeListener(signalName, handler);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
|
||||||
|
const repositoryRoot = path.resolve(import.meta.dirname, '..');
|
||||||
|
const releaseWorkflow = fs.readFileSync(
|
||||||
|
path.join(repositoryRoot, '.github', 'workflows', 'release.yml'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
test('release Linux dependency installation is mirror-normalized and bounded', () => {
|
||||||
|
assert.match(releaseWorkflow, /azure\\\.archive\\\.ubuntu\\\.com/);
|
||||||
|
assert.equal((releaseWorkflow.match(/Acquire::Retries=3/g) || []).length, 2);
|
||||||
|
assert.equal((releaseWorkflow.match(/timeout --foreground --signal=TERM --kill-after=30s 10m apt-get/g) || []).length, 2);
|
||||||
|
assert.doesNotMatch(releaseWorkflow, /^\s*sudo apt-get (update|install)/m);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user