diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5603122d3d..15af652364 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,13 @@ on: # Manual runs compare server shard counts against a fixed weights source. workflow_dispatch: inputs: + apt_route: + description: Ubuntu dependency route (candidate qualifies the isolated fallback) + type: choice + options: + - auto + - candidate + default: auto server_shards: description: Number of server test shards type: choice @@ -386,7 +393,9 @@ jobs: run: vp run --filter @t3tools/desktop ensure:electron - name: Install browser secret helper build libraries - run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config + env: + JONES_CI_APT_ROUTE: ${{ inputs.apt_route }} + run: node scripts/jones/ci/apt-setup.mjs libsecret-1-dev pkg-config - name: Build desktop pipeline run: vp run build:desktop @@ -450,9 +459,10 @@ jobs: # and soft-skip when it is missing. Install build-essential so that path # always runs in CI instead of silently no-oping. - name: Install native test build dependencies + env: + JONES_CI_APT_ROUTE: ${{ inputs.apt_route }} run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends build-essential libsecret-1-dev pkg-config + node scripts/jones/ci/apt-setup.mjs --no-install-recommends build-essential libsecret-1-dev pkg-config command -v cc - name: Setup Vite+ @@ -551,9 +561,10 @@ jobs: test "$(git rev-parse HEAD)" = "$JONES_CHECKOUT_SHA" - name: Install native test build dependencies + env: + JONES_CI_APT_ROUTE: ${{ inputs.apt_route }} run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends build-essential libsecret-1-dev pkg-config + node scripts/jones/ci/apt-setup.mjs --no-install-recommends build-essential libsecret-1-dev pkg-config command -v cc - name: Setup Vite+ diff --git a/scripts/jones/boundary/checks.json b/scripts/jones/boundary/checks.json index 95a47916c5..b4d1d6d7f2 100644 --- a/scripts/jones/boundary/checks.json +++ b/scripts/jones/boundary/checks.json @@ -3199,9 +3199,12 @@ }, { "id": "jones-ci-dependency-setup", - "tests": ["scripts/jones/ci/chromium-setup.node-test.mjs"], + "tests": [ + "scripts/jones/ci/chromium-setup.node-test.mjs", + "scripts/jones/ci/apt-setup.node-test.mjs" + ], "commands": [ - "node --test scripts/jones/ci/chromium-setup.node-test.mjs", + "node --test --test-concurrency=1 scripts/jones/ci/chromium-setup.node-test.mjs scripts/jones/ci/apt-setup.node-test.mjs", "node scripts/jones/check-boundary.ts --strict --json" ] }, diff --git a/scripts/jones/boundary/features.json b/scripts/jones/boundary/features.json index 51a710d8de..c18ef8a683 100644 --- a/scripts/jones/boundary/features.json +++ b/scripts/jones/boundary/features.json @@ -1967,8 +1967,11 @@ }, { "id": "ci-dependency-setup", - "tests": ["scripts/jones/ci/chromium-setup.node-test.mjs"], - "scope": "CI resolves Chromium from each workspace, checks a real headless launch before installing system dependencies, and verifies a single repair attempt. Browser caching follows the upstream versioned key; native compiler and libsecret package setup is consolidated without changing tests or server sharding." + "tests": [ + "scripts/jones/ci/chromium-setup.node-test.mjs", + "scripts/jones/ci/apt-setup.node-test.mjs" + ], + "scope": "CI resolves Chromium from each workspace, checks a real headless launch before installing system dependencies, and verifies a single repair attempt. Browser caching follows the upstream versioned key; native compiler and libsecret package setup is consolidated without changing tests or server sharding. Three primary Ubuntu dependency steps use strict signed refresh and download before offline install. Only proven transport failures or clean deadlines permit candidate-only isolated lists and archives, with exact source/keyring and cold index-target validation plus acquired freshness checks. An owned process-group gate proves termination before fallback or removal; cancellation and unknown cleanup stop. Injected tests cover route, trust, process and workspace boundaries; the Linux process regression runs in the existing CI Node test group. Candidate runner qualification remains required." }, { "id": "child-input-navigation", diff --git a/scripts/jones/ci/apt-setup.mjs b/scripts/jones/ci/apt-setup.mjs new file mode 100644 index 0000000000..42aa02f43e --- /dev/null +++ b/scripts/jones/ci/apt-setup.mjs @@ -0,0 +1,594 @@ +import * as NodeChildProcess from "node:child_process"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +export const candidate = "https://mirrors.edge.kernel.org/ubuntu/"; +export const workspace = "/var/lib/jones-ci-apt"; +const pockets = ["noble", "noble-updates", "noble-backports", "noble-security"]; +const components = ["main", "restricted", "universe", "multiverse"]; +const keyring = "/usr/share/keyrings/ubuntu-archive-keyring.gpg"; +const mirrorLines = [ + "http://azure.archive.ubuntu.com/ubuntu/ priority:1", + "https://archive.ubuntu.com/ubuntu/ priority:2", + "https://security.ubuntu.com/ubuntu/ priority:3", +]; +export const isolation = [ + `Dir::Etc::sourcelist=${workspace}/ubuntu.sources`, + "Dir::Etc::sourceparts=-", + `Dir::State::lists=${workspace}/lists`, + `Dir::Cache::archives=${workspace}/archives`, + "Acquire::AllowInsecureRepositories=false", + "Acquire::AllowDowngradeToInsecureRepositories=false", + "Acquire::AllowWeakRepositories=false", + "APT::Get::AllowUnauthenticated=false", +].flatMap((option) => ["-o", option]); + +const fail = (label, message) => Object.assign(new Error(`${label}: ${message}`), { label }); +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const sameSet = (values, expected) => + values.length === expected.length && + new Set(values).size === values.length && + expected.every((value) => values.includes(value)); + +export function parseArguments(args, route = "") { + if (!["", "auto", "candidate"].includes(route)) throw fail("config", "invalid apt route"); + const noRecommends = args[0] === "--no-install-recommends"; + const packages = args.slice(noRecommends ? 1 : 0); + if (!packages.length || packages.some((value) => !/^[a-z0-9][a-z0-9+.-]*$/.test(value))) { + throw fail("config", "expected package names and optional leading --no-install-recommends"); + } + return { route: route || "auto", packages, noRecommends }; +} + +function records(text) { + return text + .trim() + .split(/\n\s*\n/) + .filter(Boolean) + .map((stanza) => { + const result = {}; + for (const line of stanza.split("\n")) { + if (/^\s*#/.test(line)) continue; + const match = /^([\w-]+):[ \t]*(.*?)[ \t]*$/.exec(line); + if (!match || Object.hasOwn(result, match[1])) + throw fail("config", "unsupported deb822 record"); + result[match[1]] = match[2]; + } + return result; + }) + .filter((record) => Object.keys(record).length > 0); +} + +export function validateSources({ osRelease, architecture, mirrors, sources }) { + if (!/^VERSION_CODENAME=(?:noble|"noble")$/m.test(osRelease) || architecture.trim() !== "amd64") { + throw fail("config", "candidate requires noble amd64"); + } + const actualMirrors = mirrors + .trim() + .split("\n") + .map((line) => line.trim().replace(/\s+/g, " ")); + if (JSON.stringify(actualMirrors) !== JSON.stringify(mirrorLines)) + throw fail("config", "unexpected mirror list"); + const stanzas = records(sources); + if (stanzas.length !== 2) throw fail("config", "expected two Ubuntu source stanzas"); + const suites = []; + for (const stanza of stanzas) { + if ( + !sameSet(Object.keys(stanza), ["Types", "URIs", "Suites", "Components", "Signed-By"]) || + stanza.Types !== "deb" || + stanza.URIs !== "mirror+file:/etc/apt/apt-mirrors.txt" || + stanza["Signed-By"] !== keyring || + !sameSet(stanza.Components.split(/\s+/), components) + ) { + throw fail("config", "unsupported Ubuntu source fields or trust configuration"); + } + suites.push(...stanza.Suites.split(/\s+/)); + } + if (!sameSet(suites, pockets)) throw fail("config", "unexpected Ubuntu suites"); + return sources.replace(/^URIs:.*$/gm, `URIs: ${candidate}`); +} + +export function validateTargets(text) { + const targets = records(text); + const coverage = []; + if (!targets.length) throw fail("config", "no isolated index targets"); + for (const target of targets) { + if ( + target["Repo-URI"] !== candidate || + !target.Filename || + !NodePath.posix.resolve(target.Filename).startsWith(`${workspace}/lists/`) + ) { + throw fail("config", "index target escaped candidate or isolated lists"); + } + if (target.Identifier !== "Packages") continue; + if (!["amd64", "all"].includes(target.Architecture)) + throw fail("config", "foreign Packages architecture"); + const match = + target.URI?.startsWith(`${candidate}dists/`) && + /^([^/]+)\/([^/]+)\/binary-(amd64|all)\/Packages$/.exec( + target.URI.slice(`${candidate}dists/`.length), + ); + if ( + !match || + match[2] !== target.Component || + match[3] !== target.Architecture || + !pockets.includes(match[1]) || + !components.includes(match[2]) + ) { + throw fail("config", "unexpected Packages target"); + } + if (target.Architecture === "amd64") coverage.push(`${match[1]}/${match[2]}`); + } + if ( + !sameSet( + coverage, + pockets.flatMap((suite) => components.map((component) => `${suite}/${component}`)), + ) + ) { + throw fail("config", "incomplete isolated pocket/component coverage"); + } +} + +export function classifyFailure(result) { + if (result.cancelled) return "cancelled"; + if (!result.quiescent) return "cleanup-unresolved"; + const text = `${result.stdout}\n${result.stderr}`; + if ( + /GPG error|NO_PUBKEY|BADSIG|EXPKEYSIG|REVKEYSIG|not signed|[Hh]ash [Ss]um mismatch|[Ss]ize mismatch|File has unexpected size|[Cc]learsigned file.*(?:invalid|not valid|isn.t valid)|invalid clearsign/i.test( + text, + ) + ) + return "signature"; + if (/Release file .* (?:expired|not valid yet)|Release file .*not yet valid/i.test(text)) + return "freshness"; + if ( + /Malformed|Type .* is not known|Conflicting values|could not be read|option .*not (?:understood|recognized)|Command line option|Could not (?:get|open) lock|Unable to (?:acquire|lock)|Permission denied|are you root|sudo:|parse (?:error|failure)/i.test( + text, + ) + ) + return "config"; + const lines = text.split("\n"); + const diagnostics = []; + for (let i = 0; i < lines.length; i++) { + if (/^[EW]:/.test(lines[i])) diagnostics.push(lines[i]); + if (/^Err:/.test(lines[i])) { + diagnostics.push(/^\s+\S/.test(lines[i + 1] ?? "") ? lines[++i].trim() : lines[i]); + } + } + const transport = + /(?:Temporary failure resolving|Could not resolve|Could not connect|Unable to connect|Connection (?:timed out|timeout|reset|failed)|[Cc]onnection refused|Network is unreachable|[Tt]imeout was reached|[Cc]onnecting to .*timed out|\b(?:502 Bad Gateway|503 Service Unavailable|504 Gateway Time-out|504 Gateway Timeout)\b)/; + const summary = + /^[EW]: (?:Some index files failed to download\. They have been ignored, or old ones used instead\.|Unable to fetch some archives, maybe run apt-get update or try with --fix-missing\??)$/; + if ( + diagnostics.every((line) => transport.test(line) || summary.test(line)) && + (result.timedOut || (result.code !== 0 && diagnostics.some((line) => transport.test(line)))) + ) + return "network"; + return "unclassified"; +} + +export function parseProcStat(raw) { + const fields = raw + .slice(raw.lastIndexOf(")") + 2) + .trim() + .split(/\s+/); + if (!/^\d+$/.test(fields[2] ?? "") || !/^\d+$/.test(fields[3] ?? "")) + throw fail("cleanup-unresolved", "invalid proc identity"); + return { state: fields[0], group: Number(fields[2]), session: Number(fields[3]) }; +} + +export async function groupMembers(group) { + const members = []; + for (const pid of await NodeFSP.readdir("/proc")) { + if (!/^\d+$/.test(pid)) continue; + let stat; + try { + stat = parseProcStat(await NodeFSP.readFile(`/proc/${pid}/stat`, "utf8")); + } catch (error) { + if (error.code === "ENOENT" || error.code === "ESRCH") continue; + throw error; + } + if (stat.state !== "Z" && (stat.group === group || stat.session === group)) + members.push(Number(pid)); + } + return members; +} + +// The shell stays alive at the gate until /proc confirms its group and session. +// Descendants that create a new session, and an uncatchable helper kill, escape this guard. +const gate = + 'printf "jones-apt-group %s\\n" "$$"; IFS= read -r ack && [ "$ack" = jones-apt-go ] || exit 125; LC_ALL=C; export LC_ALL; umask 022; exec "$@"'; +export function createCommandRunner({ + launch = NodeChildProcess.spawn, + readStat = async (group) => parseProcStat(await NodeFSP.readFile(`/proc/${group}/stat`, "utf8")), + members = groupMembers, + privilege = ["/usr/bin/sudo", "-n"], + report = console.log, + now = () => performance.now(), + pause = sleep, + markerMs = 5_000, + graceMs = 10_000, + pollMs = 100, +} = {}) { + const signalGroup = async (group, signal) => { + report(`apt group ${group}: send ${signal}`); + await new Promise((resolve, reject) => { + const argv = ["/usr/bin/kill", "-s", signal, "--", `-${group}`]; + const child = launch( + privilege[0] ?? argv[0], + privilege.length ? [...privilege.slice(1), ...argv] : argv.slice(1), + { + stdio: ["ignore", "ignore", "pipe"], + env: { PATH: "/usr/sbin:/usr/bin:/sbin:/bin", LC_ALL: "C" }, + }, + ); + let stderr = ""; + child.stderr.on("data", (data) => { + stderr += data; + }); + child.once("error", reject); + child.once("exit", (code) => + code === 0 + ? resolve() + : reject(fail("cleanup-unresolved", `group signal failed: ${stderr}`)), + ); + }); + }; + const waitQuiet = async (group, ms, stopSignal) => { + const end = now() + ms; + do { + if (!(await members(group)).length) { + report(`apt group ${group}: quiescence proven`); + return true; + } + if (stopSignal?.aborted || now() >= end) return false; + await pause(pollMs); + } while (true); + }; + return async (argv, { timeoutMs = 60_000, input = "", signal } = {}) => { + if (signal?.aborted) throw fail("cancelled", "no command started"); + const command = ["/usr/bin/setsid", "--wait", "/bin/sh", "-c", gate, "jones-apt", ...argv]; + report(`apt command: ${argv.join(" ")}`); + const child = launch( + privilege[0] ?? command[0], + privilege.length ? [...privilege.slice(1), ...command] : command.slice(1), + { + stdio: ["pipe", "pipe", "pipe"], + env: { PATH: "/usr/sbin:/usr/bin:/sbin:/bin", LC_ALL: "C" }, + }, + ); + let stdout = "", + stderr = "", + prefix = "", + marker, + exited = false, + closed = false, + code, + spawnError; + let wake; + const changed = () => { + wake?.(); + wake = undefined; + }; + const waitChange = (ms) => + new Promise((resolve) => { + const timer = setTimeout(() => { + wake = undefined; + resolve(); + }, ms); + wake = () => { + clearTimeout(timer); + resolve(); + }; + }); + child.stdin.on("error", () => {}); + child.stdout.on("data", (data) => { + const text = data.toString(); + if (marker === undefined) { + prefix += text; + const newline = prefix.indexOf("\n"); + if (newline < 0) { + changed(); + return; + } + const match = /^jones-apt-group ([1-9]\d*)$/.exec(prefix.slice(0, newline)); + marker = match ? Number(match[1]) : null; + const rest = prefix.slice(newline + 1); + stdout += rest; + if (rest) report(rest.trimEnd()); + } else { + stdout += text; + report(text.trimEnd()); + } + changed(); + }); + child.stderr.on("data", (data) => { + stderr += data; + report(data.toString().trimEnd()); + changed(); + }); + child.once("error", (error) => { + spawnError = error; + exited = true; + changed(); + }); + child.once("exit", (value) => { + code = value; + exited = true; + changed(); + }); + child.once("close", () => { + closed = true; + changed(); + }); + const cancel = () => changed(); + signal?.addEventListener("abort", cancel); + const start = now(); + let group; + try { + while (marker === undefined && !exited && now() - start < markerMs) + await waitChange(Math.max(1, markerMs - (now() - start))); + if (!marker) { + child.stdin.destroy(); + throw fail( + exited ? "config" : "cleanup-unresolved", + `missing group marker${spawnError ? `: ${spawnError.message}` : ""}; ${stderr}`, + ); + } + if (!Number.isSafeInteger(marker) || marker <= 1) + throw fail("cleanup-unresolved", "invalid group marker"); + const identity = await readStat(marker); + if (identity.group !== marker || identity.session !== marker) + throw fail("cleanup-unresolved", "group/session identity mismatch"); + group = marker; + report(`apt group ${group}: captured and confirmed`); + if (!signal?.aborted) child.stdin.end(`jones-apt-go\n${input}`); + let timedOut = false; + while (!exited && !signal?.aborted) { + const remaining = timeoutMs - (now() - start); + if (remaining <= 0) { + timedOut = true; + report(`apt group ${group}: deadline hit`); + break; + } + await waitChange(remaining); + } + const terminateGroup = async () => { + if (!(await members(group)).length) { + report(`apt group ${group}: quiescence proven`); + return true; + } + // The monitored leader may have exited; the captured group still owns its survivors. + try { + await signalGroup(group, "TERM"); + } catch (error) { + if ((await members(group)).length) throw error; + } + if (await waitQuiet(group, graceMs)) return true; + try { + await signalGroup(group, "KILL"); + } catch (error) { + if ((await members(group)).length) throw error; + } + return waitQuiet(group, graceMs); + }; + let quiescent; + if (timedOut || signal?.aborted || code !== 0) quiescent = await terminateGroup(); + else { + quiescent = await waitQuiet(group, graceMs, signal); + if (!quiescent && signal?.aborted) quiescent = await terminateGroup(); + } + if (!quiescent && (timedOut || signal?.aborted || code !== 0)) + throw fail("cleanup-unresolved", `group ${group} still has members`); + if (quiescent) { + // Exit alone does not drain diagnostics; classify only after the pipes close. + const drainEnd = now() + markerMs; + while (!closed && now() < drainEnd) await waitChange(Math.max(1, drainEnd - now())); + if (!closed) + throw fail("cleanup-unresolved", `group ${group} quiet but command pipes did not close`); + } else { + report( + `apt group ${group}: successful leader left members; same route only, retain workspace`, + ); + child.unref?.(); + child.stdout.unref?.(); + child.stderr.unref?.(); + } + return { code, stdout, stderr, timedOut, cancelled: signal?.aborted === true, quiescent }; + } catch (error) { + child.stdin.destroy(); + // Without confirmed identity no numeric signal is safe. Preserve unknown effects. + child.unref?.(); + child.stdout.unref?.(); + child.stderr.unref?.(); + throw error.label ? error : fail("cleanup-unresolved", error.message); + } finally { + signal?.removeEventListener("abort", cancel); + } + }; +} + +export async function setupApt({ + args, + route = "", + run = createCommandRunner(), + readFile = (file) => NodeFSP.readFile(file, "utf8"), + now = () => Date.now(), + signal, + report = console.log, +}) { + const options = parseArguments(args, route); + const installArgs = [ + "install", + "-y", + ...(options.noRecommends ? ["--no-install-recommends"] : []), + ...options.packages, + ]; + let created = false, + allQuiet = true, + cleanupSafe = true, + fallback = false; + const command = async (argv, settings = {}) => { + if (signal?.aborted) throw fail("cancelled", "no further command started"); + let result; + try { + result = await run(argv, { ...settings, signal }); + } catch (error) { + if (error.label === "cleanup-unresolved") cleanupSafe = false; + throw error; + } + allQuiet &&= result.quiescent; + if (result.cancelled) throw fail("cancelled", "apt setup interrupted"); + return result; + }; + const requireSuccess = (result) => { + if (result.cancelled || result.code !== 0 || result.timedOut) + throw fail(classifyFailure(result), "command failed"); + return result; + }; + const apt = async (extra, action, timeoutMs) => { + const result = await command(["/usr/bin/apt-get", ...extra, ...action], { timeoutMs }); + const label = classifyFailure({ ...result, quiescent: true }); + if (["config", "signature", "freshness"].includes(label)) + throw fail(label, "terminal apt diagnostic"); + return result; + }; + try { + if (options.route === "auto") { + report("apt route: normal"); + for (const [action, deadline] of [ + [["update", "--error-on=any"], 60_000], + [[...installArgs, "--download-only"], 60_000], + ]) { + const result = await apt([], action, deadline); + if (result.code === 0 && !result.timedOut) continue; + const label = classifyFailure(result); + if (label !== "network" || !allQuiet) + throw fail(label === "network" ? "cleanup-unresolved" : label, "normal route stopped"); + fallback = true; + report("apt normal transport failure; quiescence proven before candidate"); + break; + } + // Both network steps succeeded only when the last command downloaded packages. + if (!fallback) { + requireSuccess( + await apt([], [...installArgs, "--no-download", "-o", "DPkg::Lock::Timeout=30"], 120_000), + ); + return; + } + } + report("apt route: candidate"); + const configSuccess = (result) => { + if (result.code !== 0 || result.timedOut) + throw fail("config", "candidate preflight command failed"); + return result; + }; + const architecture = configSuccess( + await command(["/usr/bin/dpkg", "--print-architecture"]), + ).stdout; + let generated, sourceConfig; + try { + sourceConfig = { + architecture, + osRelease: await readFile("/etc/os-release"), + mirrors: await readFile("/etc/apt/apt-mirrors.txt"), + sources: await readFile("/etc/apt/sources.list.d/ubuntu.sources"), + }; + generated = validateSources(sourceConfig); + } catch (error) { + throw fail("config", error.message); + } + const verified = configSuccess(await command(["/usr/bin/dpkg", "--verify", "ubuntu-keyring"])); + if (verified.stdout.trim() || verified.stderr.trim()) + throw fail("config", "ubuntu-keyring verification is not clean"); + report( + `apt preflight accepted noble amd64, signed suites/components and mirrors\nMirrors:\n${sourceConfig.mirrors}Ubuntu sources:\n${sourceConfig.sources}`, + ); + const made = await command(["/usr/bin/mkdir", "--", workspace]); + if (made.code !== 0 || made.timedOut) + throw fail("config", "workspace mkdir failed; preexisting or unknown workspace retained"); + created = true; + requireSuccess( + await command(["/usr/bin/mkdir", "--", `${workspace}/lists`, `${workspace}/archives`]), + ); + requireSuccess( + await command(["/usr/bin/tee", "--", `${workspace}/ubuntu.sources`], { input: generated }), + ); + const targets = configSuccess( + await apt(isolation, ["indextargets", "--no-release-info"], 60_000), + ); + report(`apt isolated index targets\n${targets.stdout}`); + validateTargets(targets.stdout); + requireSuccess(await apt(isolation, ["update", "--error-on=any"], 120_000)); + for (const pocket of pockets) { + let release; + try { + release = await readFile( + `${workspace}/lists/mirrors.edge.kernel.org_ubuntu_dists_${pocket}_InRelease`, + ); + } catch { + throw fail("freshness", `missing acquired InRelease: ${pocket}`); + } + if (!["noble-updates", "noble-security"].includes(pocket)) continue; + const dates = [...release.matchAll(/^Date:\s*(.+)$/gm)]; + const date = dates.length === 1 ? Date.parse(dates[0][1]) : NaN; + const age = now() - date; + if (!Number.isFinite(age) || age < 0 || age > 7 * 86_400_000) + throw fail("freshness", `invalid or stale acquired Date: ${pocket}`); + report(`apt accepted ${pocket} Date: ${dates[0][1]}`); + } + requireSuccess(await apt(isolation, [...installArgs, "--download-only"], 60_000)); + requireSuccess( + await apt( + isolation, + [...installArgs, "--no-download", "-o", "DPkg::Lock::Timeout=30"], + 120_000, + ), + ); + } finally { + if (created && cleanupSafe && allQuiet) { + const removed = await run(["/usr/bin/rm", "-r", "--", workspace], { + timeoutMs: 60_000, + signal: signal?.aborted ? undefined : signal, + }); + requireSuccess(removed); + if (!removed.quiescent) + throw fail("cleanup-unresolved", "workspace removal group still live"); + } else if (created) + report(`apt retain owned workspace ${workspace}: process quiescence unproved`); + } +} + +if ( + process.argv[1] && + import.meta.url === NodeURL.pathToFileURL(NodePath.resolve(process.argv[1])).href +) { + const controller = new AbortController(); + const cancel = () => controller.abort(); + process.on("SIGINT", cancel); + process.on("SIGTERM", cancel); + try { + // oxlint-disable-next-line t3code/no-global-process-runtime -- This standalone CI helper checks the actual runner before privileged package setup without loading application services. + if (process.platform !== "linux" || process.env.GITHUB_ACTIONS !== "true") + throw fail("config", "Ubuntu CI helper requires a disposable Linux Actions runner"); + const checkout = NodeChildProcess.execFileSync("/usr/bin/git", ["rev-parse", "HEAD"], { + encoding: "utf8", + }).trim(); + console.log(`apt setup checkout SHA: ${checkout}`); + if (process.env.GITHUB_SHA && checkout !== process.env.GITHUB_SHA) + throw fail("config", "checkout differs from GITHUB_SHA"); + await setupApt({ + args: process.argv.slice(2), + route: process.env.JONES_CI_APT_ROUTE ?? "", + signal: controller.signal, + }); + if (controller.signal.aborted) throw fail("cancelled", "apt setup interrupted"); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } finally { + process.off("SIGINT", cancel); + process.off("SIGTERM", cancel); + } +} diff --git a/scripts/jones/ci/apt-setup.node-test.mjs b/scripts/jones/ci/apt-setup.node-test.mjs new file mode 100644 index 0000000000..d4885a8faf --- /dev/null +++ b/scripts/jones/ci/apt-setup.node-test.mjs @@ -0,0 +1,621 @@ +import * as NodeAssert from "node:assert/strict"; +import * as NodeEvents from "node:events"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeStream from "node:stream"; +import * as NodeTest from "node:test"; +import { + candidate, + workspace, + isolation, + parseArguments, + validateSources, + validateTargets, + classifyFailure, + createCommandRunner, + groupMembers, + setupApt, +} from "./apt-setup.mjs"; + +const pockets = ["noble", "noble-updates", "noble-backports", "noble-security"]; +const components = ["main", "restricted", "universe", "multiverse"]; +const sources = ["noble noble-updates noble-backports", "noble-security"] + .map( + (suites) => + `Types: deb\nURIs: mirror+file:/etc/apt/apt-mirrors.txt\nSuites: ${suites}\nComponents: main restricted universe multiverse\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg\n`, + ) + .join("\n"); +const mirrors = + "http://azure.archive.ubuntu.com/ubuntu/\tpriority:1\nhttps://archive.ubuntu.com/ubuntu/\tpriority:2\nhttps://security.ubuntu.com/ubuntu/\tpriority:3\n"; +const sourceFixture = { + sources, + mirrors, + architecture: "amd64\n", + osRelease: 'VERSION_CODENAME="noble"\n', +}; +const targetText = pockets + .flatMap((pocket) => + components.map( + (component) => + `URI: ${candidate}dists/${pocket}/${component}/binary-amd64/Packages\nRepo-URI: ${candidate}\nFilename: ${workspace}/lists/${pocket}_${component}_Packages\nFallback-Of:\nIdentifier: Packages\nArchitecture: amd64\nComponent: ${component}\n`, + ), + ) + .join("\n"); +const dns = + "Err:2 https://security.ubuntu.com/ubuntu noble InRelease\n Temporary failure resolving 'azure.archive.ubuntu.com'\nW: Failed to fetch mirror+file:/etc/apt/apt-mirrors.txt/dists/noble/InRelease Temporary failure resolving 'azure.archive.ubuntu.com'\nW: Some index files failed to download. They have been ignored, or old ones used instead.\n"; +const ok = (extra = {}) => ({ + code: 0, + stdout: "", + stderr: "", + quiescent: true, + timedOut: false, + cancelled: false, + ...extra, +}); +const network = (extra = {}) => ok({ code: 100, stderr: dns, ...extra }); + +function scenario({ + intercept = () => {}, + files = {}, + route = "auto", + args = ["--no-install-recommends", "build-essential", "libsecret-1-dev", "pkg-config"], + signal, + runner, +} = {}) { + const calls = [], + reads = [], + logs = []; + const options = { + args, + route, + signal, + now: () => Date.parse("2026-11-11T16:00:00Z"), + report: (message) => logs.push(message), + async readFile(file) { + reads.push(file); + if (Object.hasOwn(files, file)) { + if (files[file] instanceof Error) throw files[file]; + return files[file]; + } + if (file === "/etc/os-release") return sourceFixture.osRelease; + if (file === "/etc/apt/apt-mirrors.txt") return mirrors; + if (file === "/etc/apt/sources.list.d/ubuntu.sources") return sources; + if (file.startsWith(`${workspace}/lists/`)) return "Date: Wed, 11 Nov 2026 15:00:00 UTC\n"; + throw new Error(`unexpected read: ${file}`); + }, + async run(argv, settings) { + calls.push({ argv, settings }); + const special = await intercept(argv, settings, calls.length); + if (special) return special; + if (runner && argv[0] === "/usr/bin/apt-get" && !argv.includes(isolation[1])) + return runner(argv, settings); + if (argv.includes("--print-architecture")) return ok({ stdout: "amd64\n" }); + if (argv.includes("indextargets")) return ok({ stdout: targetText }); + return ok(); + }, + }; + return { calls, reads, logs, options }; +} +const aptCalls = (s) => s.calls.filter(({ argv }) => argv[0] === "/usr/bin/apt-get"); +const removed = (s) => s.calls.some(({ argv }) => argv[0] === "/usr/bin/rm"); +const rejectsLabel = (promise, label) => + NodeAssert.rejects(promise, (error) => error.label === label); + +NodeTest.test( + "normal route refreshes once, downloads then installs offline with package/recommends parity", + async () => { + for (const args of [ + ["libsecret-1-dev", "pkg-config"], + ["--no-install-recommends", "build-essential", "libsecret-1-dev", "pkg-config"], + ]) { + const s = scenario({ args }); + await setupApt(s.options); + NodeAssert.deepEqual( + aptCalls(s).map(({ argv }) => argv.slice(1)), + [ + ["update", "--error-on=any"], + ["install", "-y", ...args, "--download-only"], + ["install", "-y", ...args, "--no-download", "-o", "DPkg::Lock::Timeout=30"], + ], + ); + NodeAssert.deepEqual( + s.calls.map(({ settings }) => settings.timeoutMs), + [60_000, 60_000, 120_000], + ); + NodeAssert.deepEqual(s.reads, []); + } + }, +); + +NodeTest.test( + "transport failure at refresh or download enters isolated candidate and never reuses default lists", + async () => { + for (const failingCall of [1, 2]) { + const s = scenario({ + intercept: (_argv, _settings, count) => (count === failingCall ? network() : undefined), + }); + await setupApt(s.options); + const later = aptCalls(s).slice(failingCall); + NodeAssert.equal(later.length, 4); + for (const { argv } of later) + NodeAssert.deepEqual(argv.slice(1, 1 + isolation.length), isolation); + NodeAssert.ok(later[0].argv.includes("--no-release-info")); + NodeAssert.equal(later.filter(({ argv }) => argv.includes("update")).length, 1); + NodeAssert.equal(later[1].settings.timeoutMs, 120_000); + NodeAssert.ok(later[2].argv.includes("--download-only")); + NodeAssert.ok(later[3].argv.includes("--no-download")); + NodeAssert.equal(removed(s), true); + NodeAssert.ok( + s.reads + .filter((file) => file.endsWith("InRelease")) + .every((file) => file.startsWith(`${workspace}/lists/`)), + ); + const written = s.calls.find(({ argv }) => argv[0] === "/usr/bin/tee"); + NodeAssert.equal( + written.settings.input, + sources.replace(/^URIs:.*$/gm, `URIs: ${candidate}`), + ); + } + }, +); + +NodeTest.test( + "clean deadline may fall back; candidate failure never tries healthy originals", + async () => { + const normal = scenario({ + intercept: (_a, _s, n) => (n === 1 ? network({ stderr: "", timedOut: true }) : undefined), + }); + await setupApt(normal.options); + NodeAssert.equal(aptCalls(normal).length, 5); + const forced = scenario({ + route: "candidate", + intercept: (argv) => (argv.includes("update") ? network() : undefined), + }); + await rejectsLabel(setupApt(forced.options), "network"); + NodeAssert.ok(aptCalls(forced).every(({ argv }) => argv.includes(isolation[1]))); + NodeAssert.equal(removed(forced), true); + }, +); + +NodeTest.test( + "terminal diagnostics override transport/timeouts; exit 100 and HTTP 404 stay unclassified", + async () => { + for (const [diagnostic, label] of [ + ["E: GPG error: NO_PUBKEY bad", "signature"], + ["E: Hash Sum mismatch", "signature"], + ["E: Release file for URL is expired (invalid since 2d)", "freshness"], + ["E: Could not get lock /var/lib/dpkg/lock - open (13: Permission denied)", "config"], + ["E: Command line option --bad is not understood", "config"], + ["sudo: a password is required", "config"], + ["E: unexplained apt failure", "unclassified"], + ["E: Failed to fetch https://example/file 404 Not Found", "unclassified"], + ]) { + for (const timedOut of [false, true]) { + const result = network({ stderr: `${dns}${diagnostic}\n`, timedOut }); + NodeAssert.equal(classifyFailure(result), label); + const s = scenario({ intercept: (_a, _s, n) => (n === 1 ? result : undefined) }); + await rejectsLabel(setupApt(s.options), label); + NodeAssert.equal(s.calls.length, 1); + } + if (["config", "signature", "freshness"].includes(label)) { + const s = scenario({ + intercept: (_a, _s, n) => (n === 1 ? ok({ stderr: diagnostic }) : undefined), + }); + await rejectsLabel(setupApt(s.options), label); + NodeAssert.equal(s.calls.length, 1); + } + } + NodeAssert.equal(classifyFailure(network({ stderr: "" })), "unclassified"); + NodeAssert.equal( + classifyFailure( + network({ + stderr: + "E: Unable to fetch some archives, maybe run apt-get update or try with --fix-missing?", + }), + ), + "unclassified", + ); + }, +); + +NodeTest.test( + "preflight rejects altered suite, keyring, mirrors, directives, codename or architecture", + () => { + NodeAssert.equal( + validateSources(sourceFixture), + sources.replace(/^URIs:.*$/gm, `URIs: ${candidate}`), + ); + const withHeader = `# Image-owned Ubuntu configuration\n\n${sources}`; + NodeAssert.equal( + validateSources({ ...sourceFixture, sources: withHeader }), + withHeader.replace(/^URIs:.*$/gm, `URIs: ${candidate}`), + ); + for (const change of [ + { sources: sources.replace("noble-backports", "jammy") }, + { sources: sources.replaceAll("ubuntu-archive-keyring.gpg", "other.gpg") }, + { mirrors: mirrors.replace("priority:3", "priority:4") }, + { sources: `${sources}Trusted: yes\n` }, + { sources: `${sources}Check-Valid-Until: no\n` }, + { sources: sources.replace("Components: main", "Snapshot: test\nComponents: main") }, + { architecture: "arm64" }, + { osRelease: "VERSION_CODENAME=jammy" }, + ]) + NodeAssert.throws( + () => validateSources({ ...sourceFixture, ...change }), + (error) => error.label === "config", + ); + }, +); + +NodeTest.test( + "cold isolation proof requires candidate URI, all 4x4 amd64 targets and confined filenames without release fields", + () => { + validateTargets(targetText); + for (const text of [ + targetText.replaceAll("noble-security", "jammy-security"), + targetText.replace("Repo-URI: " + candidate, "Repo-URI: https://archive.ubuntu.com/ubuntu/"), + targetText.replace(`Filename: ${workspace}/lists/`, "Filename: /var/lib/apt/lists/"), + targetText + .replaceAll("binary-amd64", "binary-arm64") + .replaceAll("Architecture: amd64", "Architecture: arm64"), + targetText.split(/\n\n/).slice(1).join("\n\n"), + "", + ]) + NodeAssert.throws( + () => validateTargets(text), + (error) => error.label === "config", + ); + }, +); + +NodeTest.test( + "candidate preflight, keyring and target-proof failures stop before network", + async () => { + for (const kind of ["source", "keyring", "targets"]) { + const s = scenario({ + route: "candidate", + files: + kind === "source" + ? { "/etc/apt/sources.list.d/ubuntu.sources": sources + "Trusted: yes\n" } + : {}, + intercept: (argv) => + kind === "keyring" && argv.includes("--verify") + ? ok({ stdout: "changed keyring" }) + : kind === "targets" && argv.includes("indextargets") + ? ok({ stdout: targetText.replaceAll(candidate, "https://foreign/") }) + : undefined, + }); + await rejectsLabel(setupApt(s.options), "config"); + NodeAssert.equal( + aptCalls(s).some(({ argv }) => argv.includes("update")), + false, + ); + NodeAssert.equal(removed(s), kind === "targets"); + } + }, +); + +NodeTest.test("a preexisting workspace is never removed", async () => { + const s = scenario({ + route: "candidate", + intercept: (argv) => + argv[0] === "/usr/bin/mkdir" ? ok({ code: 1, stderr: "File exists" }) : undefined, + }); + await rejectsLabel(setupApt(s.options), "config"); + NodeAssert.equal(removed(s), false); + NodeAssert.equal(aptCalls(s).length, 0); +}); + +NodeTest.test( + "freshness uses only acquired files and rejects missing, malformed, stale or future dates", + async () => { + for (const value of [ + new Error("missing"), + "Date: nonsense\n", + "Date: Sun, 01 Nov 2026 15:00:00 UTC\n", + "Date: Thu, 12 Nov 2026 15:00:00 UTC\n", + "Date: Wed, 11 Nov 2026 15:00:00 UTC\nDate: Wed, 11 Nov 2026 15:00:00 UTC\n", + ]) { + const s = scenario({ + route: "candidate", + files: { + [`${workspace}/lists/mirrors.edge.kernel.org_ubuntu_dists_noble-security_InRelease`]: + value, + }, + }); + await rejectsLabel(setupApt(s.options), "freshness"); + NodeAssert.equal( + aptCalls(s).some(({ argv }) => argv.includes("install")), + false, + ); + NodeAssert.equal(removed(s), true); + } + }, +); + +NodeTest.test( + "invalid positional packages/options and selectors cannot become privileged path overrides", + async () => { + for (const args of [ + [], + ["-o"], + ["pkg;true"], + ["/tmp/pkg"], + ["pkg", "--no-install-recommends"], + ["--no-install-recommends"], + ]) { + NodeAssert.throws( + () => parseArguments(args), + (error) => error.label === "config", + ); + } + for (const route of ["other", "AUTO", "/tmp/sources"]) + NodeAssert.throws( + () => parseArguments(["pkg"], route), + (error) => error.label === "config", + ); + }, +); + +function processFixture({ + survivor = false, + immortal = false, + lingering = false, + missing = false, + liveMissing = false, + cancel, + cancelOnQuiet, + fastExit, + lateDiagnostic, +} = {}) { + const events = [], + logs = []; + let alive = false, + leader, + tick = 0; + const launch = (file, argv) => { + const child = new NodeEvents.EventEmitter(); + child.stdout = new NodeStream.PassThrough(); + child.stderr = new NodeStream.PassThrough(); + child.stdin = new NodeStream.Writable({ + write(_chunk, _encoding, done) { + done(); + }, + }); + if (file === "/usr/bin/kill") { + NodeAssert.equal(argv.at(-1), "-712"); + const sig = argv[1]; + events.push(sig); + queueMicrotask(() => { + if (sig === "TERM") { + leader.emit("exit", null, "SIGTERM"); + if (!survivor) alive = false; + } + if (sig === "KILL" && !immortal) alive = false; + child.emit("exit", 0); + leader.emit("close"); + }); + } else { + NodeAssert.equal(file, "/usr/bin/setsid"); + NodeAssert.deepEqual(argv.slice(0, 3), ["--wait", "/bin/sh", "-c"]); + leader = child; + queueMicrotask(() => { + alive = true; + if (!missing) child.stdout.write("jones-apt-group 712\n"); + else if (!liveMissing) { + alive = false; + child.emit("exit", 1); + child.emit("close"); + } + }); + child.stdin.on("finish", () => { + events.push("ack"); + if (cancel) cancel(); + if (lingering) queueMicrotask(() => child.emit("exit", 0)); + if (fastExit !== undefined) + queueMicrotask(() => { + alive = false; + child.emit("exit", fastExit); + setImmediate(() => { + if (lateDiagnostic) child.stderr.write(lateDiagnostic); + child.emit("close"); + }); + }); + }); + } + return child; + }; + const run = createCommandRunner({ + launch, + privilege: [], + report: (line) => logs.push(line), + readStat: async () => { + events.push("identity"); + return { group: 712, session: 712 }; + }, + members: async () => { + if (cancelOnQuiet) cancelOnQuiet(); + return alive ? [713] : []; + }, + markerMs: 15, + graceMs: 5, + pollMs: 1, + now: () => performance.now() + tick, + pause: async (ms) => { + tick += ms; + }, + }); + return { run, events, logs }; +} + +NodeTest.test( + "owned runner confirms live identity before ack; TERM leader exit still escalates survivor to KILL before fallback", + async () => { + const fixture = processFixture({ survivor: true }); + const s = scenario({ + runner: (argv, settings) => fixture.run(argv, { ...settings, timeoutMs: 15 }), + }); + await setupApt(s.options); + NodeAssert.deepEqual(fixture.events, ["identity", "ack", "TERM", "KILL"]); + NodeAssert.ok(fixture.logs.some((line) => line.includes("712: deadline hit"))); + NodeAssert.ok(fixture.logs.some((line) => line.includes("712: quiescence proven"))); + NodeAssert.equal(s.calls[1].argv[0], "/usr/bin/dpkg"); + }, +); + +NodeTest.test( + "survivor outliving KILL stops candidate and retains an owned workspace", + async () => { + const fixture = processFixture({ survivor: true, immortal: true }); + const s = scenario({ + route: "candidate", + intercept: (argv) => + argv.includes("update") ? fixture.run(argv, { timeoutMs: 15 }) : undefined, + }); + await rejectsLabel(setupApt(s.options), "cleanup-unresolved"); + NodeAssert.deepEqual(fixture.events, ["identity", "ack", "TERM", "KILL"]); + NodeAssert.equal(removed(s), false); + NodeAssert.equal( + aptCalls(s).some(({ argv }) => argv.includes("install")), + false, + ); + }, +); + +NodeTest.test("cancellation terminates the current group and never falls back", async () => { + const controller = new AbortController(); + const fixture = processFixture({ survivor: true, cancel: () => controller.abort() }); + const s = scenario({ signal: controller.signal, runner: fixture.run }); + await rejectsLabel(setupApt(s.options), "cancelled"); + NodeAssert.equal(s.calls.length, 1); + NodeAssert.deepEqual(fixture.events, ["identity", "ack", "TERM", "KILL"]); +}); + +NodeTest.test( + "successful lingering member continues only the same route, never signals and skips removal", + async () => { + const fixture = processFixture({ lingering: true }); + const s = scenario({ + route: "candidate", + intercept: (argv) => (argv.includes("update") ? fixture.run(argv) : undefined), + }); + await setupApt(s.options); + NodeAssert.deepEqual(fixture.events, ["identity", "ack"]); + NodeAssert.equal(removed(s), false); + NodeAssert.equal(aptCalls(s).filter(({ argv }) => argv.includes("install")).length, 2); + const failure = scenario({ + intercept: (_a, _s, n) => + n === 1 ? ok({ quiescent: false }) : n === 2 ? network() : undefined, + }); + await rejectsLabel(setupApt(failure.options), "cleanup-unresolved"); + NodeAssert.equal(failure.calls.length, 2); + }, +); + +NodeTest.test( + "fast gated commands wait for diagnostics after exit before classification", + async () => { + const quick = processFixture({ fastExit: 0 }); + const result = await quick.run(["/bin/true"]); + NodeAssert.equal(result.code, 0); + NodeAssert.deepEqual(quick.events, ["identity", "ack"]); + const late = processFixture({ + fastExit: 100, + lateDiagnostic: `${dns}E: GPG error: BADSIG bad\n`, + }); + const s = scenario({ runner: late.run }); + await rejectsLabel(setupApt(s.options), "signature"); + NodeAssert.equal(s.calls.length, 1); + }, +); + +NodeTest.test("cancellation during the post-exit wait terminates surviving members", async () => { + const controller = new AbortController(); + const fixture = processFixture({ + lingering: true, + survivor: true, + cancelOnQuiet: () => controller.abort(), + }); + const result = await fixture.run(["/bin/true"], { signal: controller.signal }); + NodeAssert.equal(result.cancelled, true); + NodeAssert.equal(result.quiescent, true); + NodeAssert.deepEqual(fixture.events, ["identity", "ack", "TERM", "KILL"]); +}); + +NodeTest.test( + "missing marker distinguishes exited child from live unresolved child without signaling an unconfirmed group", + async () => { + for (const liveMissing of [false, true]) { + const fixture = processFixture({ missing: true, liveMissing }); + await rejectsLabel(fixture.run(["/bin/true"]), liveMissing ? "cleanup-unresolved" : "config"); + NodeAssert.deepEqual(fixture.events, []); + } + }, +); + +NodeTest.test( + "Linux real process: TERM exits leader but KILL reaps its surviving group before returning", + { + skip: + // oxlint-disable-next-line t3code/no-global-process-runtime -- This standalone process regression must identify the actual host before using Linux process groups. + process.platform !== "linux" + ? "requires Linux setsid, /proc and kill; Mac does not qualify production process machinery" + : false, + }, + async (t) => { + const logs = []; + // The child ignores TERM before telling its parent it is ready. No files or ports are created. + const script = `trap 'exit 0' TERM +/bin/sh -c 'trap "" TERM; printf "jones-survivor-ready\\n"; while :; do sleep 60; done' & +wait`; + let survivorReady = false; + const run = createCommandRunner({ + privilege: [], + graceMs: 100, + pollMs: 10, + report: (line) => { + logs.push(line); + if (line.includes("jones-survivor-ready")) survivorReady = true; + }, + }); + const result = await run(["/bin/sh", "-c", script], { timeoutMs: 1_000, signal: t.signal }); + NodeAssert.equal(survivorReady, true); + NodeAssert.equal(result.timedOut, true); + NodeAssert.equal(result.quiescent, true); + NodeAssert.ok(logs.some((line) => line.endsWith("send TERM"))); + NodeAssert.ok(logs.some((line) => line.endsWith("send KILL"))); + const group = Number( + logs.find((line) => line.includes("captured and confirmed")).match(/group (\d+)/)[1], + ); + NodeAssert.deepEqual(await groupMembers(group), []); + }, +); + +NodeTest.test( + "primary workflow uses one exact helper call per package set, preserves compiler checks and dispatch selection", + async () => { + const workflow = await NodeFSP.readFile( + new URL("../../../.github/workflows/ci.yml", import.meta.url), + "utf8", + ); + for (const job of ["build", "test", "test_desktop"]) { + const block = workflow.split(`\n ${job}:\n`)[1].split(/\n [\w-]+:\n/)[0]; + NodeAssert.equal(block.match(/node scripts\/jones\/ci\/apt-setup.mjs/g)?.length, 1); + NodeAssert.doesNotMatch(block, /apt-get/); + const expected = + job === "build" + ? "libsecret-1-dev pkg-config" + : "--no-install-recommends build-essential libsecret-1-dev pkg-config"; + NodeAssert.ok(block.includes(`node scripts/jones/ci/apt-setup.mjs ${expected}\n`)); + NodeAssert.match(block, /JONES_CI_APT_ROUTE: \$\{\{ inputs.apt_route \}\}/); + if (job !== "build") NodeAssert.match(block, /command -v cc/); + } + NodeAssert.match( + workflow, + /apt_route:\n[\s\S]*?type: choice\n\s+options:\n\s+- auto\n\s+- candidate\n\s+default: auto/, + ); + NodeAssert.match( + workflow, + /node --test --test-concurrency=1 scripts\/jones\/ci\/\*\.node-test\.\*/, + ); + }, +); diff --git a/scripts/jones/ci/chromium-setup.node-test.mjs b/scripts/jones/ci/chromium-setup.node-test.mjs index e743f31202..2217116c7d 100644 --- a/scripts/jones/ci/chromium-setup.node-test.mjs +++ b/scripts/jones/ci/chromium-setup.node-test.mjs @@ -11,7 +11,7 @@ NodeTest.test("CI avoids unconditional Chromium apt work and duplicate per-job a NodeAssert.doesNotMatch(workflow, /install --with-deps --only-shell chromium/); for (const job of ["test", "test_desktop"]) { const packageJob = workflow.split(`\n ${job}:\n`)[1].split(/\n [\w-]+:\n/)[0]; - NodeAssert.equal(packageJob.match(/sudo apt-get update/g)?.length, 1, job); + NodeAssert.equal(packageJob.match(/node scripts\/jones\/ci\/apt-setup\.mjs/g)?.length, 1, job); NodeAssert.match(packageJob, /build-essential libsecret-1-dev pkg-config/); NodeAssert.match(packageJob, /command -v cc/); }