diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 557f3c4..4698618 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,9 @@ jobs: - name: Verify browser harness build cleanup run: node --test scripts/fixtures/replay-process.test.mjs + - name: Verify browser runner mirror preparation + run: node --test scripts/fixtures/browser-mirrors.test.mjs + - name: Verify Docker cloud runner offline run: | pnpm exec tsx --test scripts/cloud/*.test.ts @@ -193,7 +196,14 @@ jobs: with: name: package-dist path: dist/packages/ + # Prefer the runner's existing Ubuntu archive mirrors over its slow Azure mirror. + # Preserve package sources, signatures and Azure as a final fallback. + - name: Prepare browser dependency mirrors + run: | + node scripts/fixtures/browser-mirrors.mjs /etc/apt/apt-mirrors.txt "${RUNNER_TEMP}/domscribe-apt-mirrors.txt" + sudo install -m 644 "${RUNNER_TEMP}/domscribe-apt-mirrors.txt" /etc/apt/apt-mirrors.txt - name: Install Chromium + timeout-minutes: 5 run: pnpm --filter @domscribe/test-fixtures exec playwright install --with-deps chromium - name: Replay persisted captures in a fresh browser session run: >- @@ -297,7 +307,12 @@ jobs: - run: pnpm install --frozen-lockfile + - name: Prepare browser dependency mirrors + run: | + node scripts/fixtures/browser-mirrors.mjs /etc/apt/apt-mirrors.txt "${RUNNER_TEMP}/domscribe-apt-mirrors.txt" + sudo install -m 644 "${RUNNER_TEMP}/domscribe-apt-mirrors.txt" /etc/apt/apt-mirrors.txt - name: Install Playwright browsers + timeout-minutes: 5 run: pnpm --filter @domscribe/test-fixtures exec playwright install --with-deps chromium - name: Download registry storage diff --git a/scripts/fixtures/browser-mirrors.mjs b/scripts/fixtures/browser-mirrors.mjs new file mode 100644 index 0000000..fa8fc8d --- /dev/null +++ b/scripts/fixtures/browser-mirrors.mjs @@ -0,0 +1,76 @@ +import { readFileSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +export function preferUbuntuMirrors(content) { + const order = [ + 'archive.ubuntu.com', + 'security.ubuntu.com', + 'azure.archive.ubuntu.com', + ]; + const comments = [], + rows = []; + const unsupported = () => { + throw new Error('Unsupported Ubuntu runner mirror list'); + }; + for (const line of content.split(/\r?\n/)) { + if (!line.trim() || line.trimStart().startsWith('#')) { + comments.push(line); + continue; + } + const match = line.match(/^(https?:\/\/[^\s]+)\t(.+)$/); + if (!match) unsupported(); + let uri; + try { + uri = new URL(match[1]); + } catch { + // Native URL errors retain input; do not echo unfamiliar runner data. + unsupported(); + } + const metadata = match[2].trim().split(/\s+/); + const priorities = metadata.filter((value) => + value.startsWith('priority:'), + ); + if ( + !order.includes(uri.hostname) || + uri.username || + uri.password || + uri.search || + uri.hash || + uri.port || + !/^\/ubuntu\/?$/.test(uri.pathname) || + priorities.length !== 1 || + !/^priority:\d+$/.test(priorities[0]) + ) + unsupported(); + rows.push({ uri: match[1], host: uri.hostname, metadata }); + } + if ( + rows.length !== order.length || + new Set(rows.map((row) => row.host)).size !== order.length + ) + unsupported(); + rows.sort((a, b) => order.indexOf(a.host) - order.indexOf(b.host)); + return ( + [ + ...comments.filter((line) => line.trim()), + ...rows.map( + (row, i) => + `${row.uri}\t${row.metadata.map((value) => (/^priority:\d+$/.test(value) ? `priority:${i + 1}` : value)).join(' ')}`, + ), + ].join('\n') + '\n' + ); +} + +if ( + process.argv[1] && + pathToFileURL(resolve(process.argv[1])).href === import.meta.url +) { + const [input, output] = process.argv.slice(2); + if (!input || !output || resolve(input) === resolve(output)) { + throw new Error( + 'Usage: browser-mirrors.mjs ', + ); + } + writeFileSync(output, preferUbuntuMirrors(readFileSync(input, 'utf8'))); +} diff --git a/scripts/fixtures/browser-mirrors.test.mjs b/scripts/fixtures/browser-mirrors.test.mjs new file mode 100644 index 0000000..fc4461f --- /dev/null +++ b/scripts/fixtures/browser-mirrors.test.mjs @@ -0,0 +1,127 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { preferUbuntuMirrors } from './browser-mirrors.mjs'; + +const runnerList = + [ + 'http://azure.archive.ubuntu.com/ubuntu\tpriority:1', + 'http://archive.ubuntu.com/ubuntu\tpriority:2', + 'http://security.ubuntu.com/ubuntu\tpriority:3', + ].join('\n') + '\n'; + +test('prefer existing official Ubuntu mirrors while retaining Azure fallback', () => { + const rows = preferUbuntuMirrors(runnerList).trim().split('\n'); + assert.equal(rows[0], 'http://archive.ubuntu.com/ubuntu\tpriority:1'); + assert.equal(rows[1], 'http://security.ubuntu.com/ubuntu\tpriority:2'); + assert.equal(rows[2], 'http://azure.archive.ubuntu.com/ubuntu\tpriority:3'); +}); + +test('preserve transport, metadata and comments without adding package sources', () => { + const source = + '# Hosted runner mirrors\n\n' + + runnerList + .replaceAll('http:', 'https:') + .replace('priority:2', 'priority:2 arch:amd64'); + const prepared = preferUbuntuMirrors(source); + assert.match( + prepared, + /https:\/\/archive\.ubuntu\.com\/ubuntu\tpriority:1 arch:amd64/, + ); + assert.match(prepared, /^# Hosted runner mirrors/m); + assert.equal((prepared.match(/https:/g) ?? []).length, 3); +}); + +test('refuse unfamiliar or incomplete runner lists', () => { + for (const source of [ + runnerList.replace( + 'archive.ubuntu.com/ubuntu\tpriority:2', + 'mirror.example/ubuntu\tpriority:2', + ), + runnerList.replace(/^http:\/\/azure[^\n]+\n/m, ''), + runnerList + 'http://archive.ubuntu.com/ubuntu\tpriority:4\n', + runnerList.replace('priority:2', ''), + ]) + assert.throws( + () => preferUbuntuMirrors(source), + /Unsupported Ubuntu runner mirror list/, + ); +}); + +test('reject a malformed duplicate priority instead of preserving it', () => { + assert.throws( + () => + preferUbuntuMirrors( + runnerList.replace('priority:2', 'priority:2 priority:invalid'), + ), + /Unsupported Ubuntu runner mirror list/, + ); +}); + +test('malformed URLs use a generic error without retaining raw input', () => { + assert.throws( + () => + preferUbuntuMirrors( + runnerList.replace( + 'http://archive.ubuntu.com/ubuntu', + 'http://[invalid]/ubuntu', + ), + ), + (error) => + error.message === 'Unsupported Ubuntu runner mirror list' && + !('input' in error), + ); +}); + +test('CLI writes a prepared copy and leaves the runner input unchanged', () => { + const root = mkdtempSync(join(tmpdir(), 'domscribe-browser-mirrors-')); + try { + const input = join(root, 'input'), + output = join(root, 'output'); + writeFileSync(input, runnerList); + const result = spawnSync( + process.execPath, + [ + new URL('./browser-mirrors.mjs', import.meta.url).pathname, + input, + output, + ], + { encoding: 'utf8' }, + ); + assert.equal(result.status, 0, result.stderr); + assert.equal(readFileSync(input, 'utf8'), runnerList); + assert.match( + readFileSync(output, 'utf8'), + /^http:\/\/archive\.ubuntu\.com\/ubuntu\tpriority:1/m, + ); + } finally { + rmSync(root, { recursive: true }); + } +}); + +test('invalid CLI input fails without replacing an existing prepared copy', () => { + const root = mkdtempSync(join(tmpdir(), 'domscribe-browser-mirrors-')); + try { + const input = join(root, 'input'), + output = join(root, 'output'); + writeFileSync(input, 'http://mirror.example/ubuntu\tpriority:1\n'); + writeFileSync(output, 'unchanged'); + const result = spawnSync( + process.execPath, + [ + new URL('./browser-mirrors.mjs', import.meta.url).pathname, + input, + output, + ], + { encoding: 'utf8' }, + ); + assert.notEqual(result.status, 0); + assert.equal(readFileSync(output, 'utf8'), 'unchanged'); + assert.doesNotMatch(result.stderr, /mirror\.example/); + } finally { + rmSync(root, { recursive: true }); + } +});