From 0ecfc06494c4aeb6df52a3e3ac0d3b227a582e90 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Wed, 23 Sep 2026 22:55:48 +0200 Subject: [PATCH] test(electron): fix Windows cleanup and retain packaged smoke diagnostics (#1665) * test(electron): reap Windows process trees and retain smoke diagnostics * test(electron): require confirmed process exit before relaunch * test(electron): retain process handle after application exit * test(electron): bind captured processes to application instances --- .github/workflows/build-and-make.yaml | 10 ++ .github/workflows/e2e-tests.yaml | 3 + .../src/electron-process-lifecycle.ts | 48 +++-- .../src/electron-process-termination.ts | 25 +++ .../src/electron-test-fixtures.ts | 106 +++-------- .../src/legacy-playlist-migration.e2e.ts | 2 + .../electron-process-lifecycle.spec.ts | 166 +++++++++++++++++- .../electron-process-termination.spec.ts | 112 ++++++++++++ docs/development/electron-debugging.md | 24 +++ .../snap-workflow-policy.test-helpers.mjs | 1 + 10 files changed, 402 insertions(+), 95 deletions(-) create mode 100644 apps/electron-backend-e2e/src/electron-process-termination.ts create mode 100644 apps/electron-backend-e2e/src/performance/electron-process-termination.spec.ts diff --git a/.github/workflows/build-and-make.yaml b/.github/workflows/build-and-make.yaml index 4b2e724db..d901a20ee 100644 --- a/.github/workflows/build-and-make.yaml +++ b/.github/workflows/build-and-make.yaml @@ -1150,6 +1150,16 @@ jobs: electron-backend-e2e:packaged-frame-copy-smoke \ --skip-nx-cache + - name: Upload packaged frame-copy smoke diagnostics + if: always() && matrix.os == 'linux' && matrix.linux_profile == 'portable' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: packaged-frame-copy-smoke + path: | + dist/playwright-report/electron-backend-e2e/packaged-frame-copy-smoke/ + dist/test-results/electron-backend-e2e/packaged-frame-copy-smoke/ + retention-days: 7 + - name: Diagnose packaged x64 frame-copy hardware path if: matrix.os == 'linux' && matrix.linux_profile == 'portable' continue-on-error: true diff --git a/.github/workflows/e2e-tests.yaml b/.github/workflows/e2e-tests.yaml index 5879a477f..74ff1e198 100644 --- a/.github/workflows/e2e-tests.yaml +++ b/.github/workflows/e2e-tests.yaml @@ -68,6 +68,9 @@ jobs: - name: Build Backend run: pnpm nx build electron-backend + - name: Verify Electron process cleanup + run: pnpm exec tsx --test apps/electron-backend-e2e/src/performance/electron-process-lifecycle.spec.ts apps/electron-backend-e2e/src/performance/electron-process-termination.spec.ts + - name: Install Playwright Browsers run: pnpm exec playwright install --with-deps diff --git a/apps/electron-backend-e2e/src/electron-process-lifecycle.ts b/apps/electron-backend-e2e/src/electron-process-lifecycle.ts index 51841fcd1..d0302668b 100644 --- a/apps/electron-backend-e2e/src/electron-process-lifecycle.ts +++ b/apps/electron-backend-e2e/src/electron-process-lifecycle.ts @@ -1,13 +1,13 @@ import type { ChildProcess } from 'node:child_process'; +import { terminateElectronProcess } from './electron-process-termination'; type ElectronChildProcess = Pick< ChildProcess, - 'exitCode' | 'kill' | 'once' | 'removeListener' | 'signalCode' + 'exitCode' | 'kill' | 'once' | 'pid' | 'removeListener' | 'signalCode' >; export interface ClosableElectronApplication { close(): Promise; - process(): ElectronChildProcess; } export interface ElectronExitConfirmationOptions { @@ -15,6 +15,19 @@ export interface ElectronExitConfirmationOptions { readonly exitTimeoutMs: number; } +const electronProcesses = new WeakMap< + ClosableElectronApplication, + ElectronChildProcess +>(); + +export function captureElectronProcess( + application: ClosableElectronApplication & { process(): Process } +): Process { + const child = application.process(); + electronProcesses.set(application, child); + return child; +} + export interface PrepareElectronApplicationOptions { readonly application: Application; readonly dispose: (application: Application) => Promise; @@ -49,11 +62,19 @@ export async function prepareElectronApplication( export async function closeElectronApplicationAndConfirmExit( application: ClosableElectronApplication, - options: ElectronExitConfirmationOptions + options: ElectronExitConfirmationOptions, + terminate: ( + child: ElectronChildProcess, + signal: NodeJS.Signals + ) => void = terminateElectronProcess ): Promise { assertTimeout(options.closeTimeoutMs); assertTimeout(options.exitTimeoutMs); - const child = application.process(); + // Playwright discards its process dispatcher when Electron exits. Keep + // using the Node handle captured immediately after launch, including when + // the application's last window already caused a normal process exit. + const child = electronProcesses.get(application); + if (!child) throw new Error('electron-process-handle-not-captured'); if (hasExited(child)) return; const exit = observeProcessExit(child); @@ -82,14 +103,19 @@ export async function closeElectronApplicationAndConfirmExit( } if (first.kind === 'close-rejected') failure = first.failure; - try { - child.kill(); - } catch (killFailure) { - failure = failure - ? new AggregateError([failure, killFailure]) - : killFailure; + // A failed termination must not skip exit observation or let a + // caller restart against a profile that is still owned by Electron. + for (const signal of ['SIGTERM', 'SIGKILL'] as const) { + try { + terminate(child, signal); + } catch (killFailure) { + failure = failure + ? new AggregateError([failure, killFailure]) + : killFailure; + } + if (await resolvesWithin(exit.promise, options.exitTimeoutMs)) + return; } - if (await resolvesWithin(exit.promise, options.exitTimeoutMs)) return; } finally { exit.cancel(); } diff --git a/apps/electron-backend-e2e/src/electron-process-termination.ts b/apps/electron-backend-e2e/src/electron-process-termination.ts new file mode 100644 index 000000000..9f239eb25 --- /dev/null +++ b/apps/electron-backend-e2e/src/electron-process-termination.ts @@ -0,0 +1,25 @@ +import { execFileSync, type ChildProcess } from 'node:child_process'; + +type ElectronProcess = Pick; + +export function terminateElectronProcess( + child: ElectronProcess, + signal: NodeJS.Signals = 'SIGTERM', + platform: NodeJS.Platform = process.platform, + run: typeof execFileSync = execFileSync +): void { + // Playwright launches Electron through cmd.exe on Windows. Killing only + // that ChildProcess leaves Electron (and its profile lock) alive. + if (platform === 'win32') { + if (!Number.isSafeInteger(child.pid) || (child.pid ?? 0) <= 0) { + throw new Error('electron-process-pid-unavailable'); + } + run('taskkill.exe', ['/pid', String(child.pid), '/T', '/F'], { + stdio: 'pipe', + timeout: 5000, + windowsHide: true, + }); + return; + } + child.kill(signal); +} diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index 3702752cd..6aa458b81 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -7,7 +7,7 @@ import { Page, test as base, } from '@playwright/test'; -import { spawn } from 'child_process'; +import { spawn, type ChildProcess } from 'child_process'; import { createServer, Server } from 'http'; import { accessSync, @@ -28,6 +28,7 @@ import { writeDataDirOwnerMarker, } from './data-dir-reaper'; import { + captureElectronProcess, closeElectronApplicationAndConfirmExit, prepareElectronApplication, } from './electron-process-lifecycle'; @@ -234,6 +235,7 @@ export async function launchElectronApp( args, env: buildElectronLaunchEnvironment(dataDir, options), }); + const electronProcess = captureElectronProcess(electronApp); return prepareElectronApplication({ application: electronApp, dispose: (application) => @@ -242,7 +244,7 @@ export async function launchElectronApp( exitTimeoutMs: electronAppKillWaitMs, }), prepare: async (application) => { - attachElectronProcessDiagnostics(application); + attachElectronProcessDiagnostics(electronProcess); const mainWindow = await findMainWindow(application); await waitForAppReady(mainWindow); await startPortalDebugCapture(mainWindow); @@ -458,26 +460,31 @@ export async function launchPackagedElectronApp( NODE_ENV: 'test', }, }); - attachElectronProcessDiagnostics(electronApp); - - const mainWindow = await findMainWindow(electronApp); - await waitForAppReady(mainWindow); - - return { - electronApp, - mainWindow, - }; + const electronProcess = captureElectronProcess(electronApp); + return prepareElectronApplication({ + application: electronApp, + dispose: (application) => + closeElectronApplicationAndConfirmExit(application, { + closeTimeoutMs: electronAppCloseTimeoutMs, + exitTimeoutMs: electronAppKillWaitMs, + }), + prepare: async (application) => { + attachElectronProcessDiagnostics(electronProcess); + const mainWindow = await findMainWindow(application); + await waitForAppReady(mainWindow); + return { + electronApp: application, + mainWindow, + }; + }, + }); } -function attachElectronProcessDiagnostics( - electronApp: ElectronApplication -): void { +function attachElectronProcessDiagnostics(childProcess: ChildProcess): void { if (!process.env['CI']) { return; } - const childProcess = electronApp.process(); - childProcess.stdout?.on('data', (chunk: Buffer) => { console.log(`[electron stdout] ${chunk.toString().trimEnd()}`); }); @@ -564,52 +571,7 @@ export async function launchCompetingElectronInstance( export async function closeElectronApp( app: LaunchedElectronApp ): Promise { - try { - const closePromise = app.electronApp.close(); - const closed = await waitForPromiseWithTimeout( - closePromise, - electronAppCloseTimeoutMs - ); - - if (closed) { - return; - } - - console.warn( - `Electron app did not close within ${electronAppCloseTimeoutMs}ms; killing process` - ); - const childProcess = app.electronApp.process(); - - if (!childProcess.killed) { - childProcess.kill(); - } - - await waitForPromiseWithTimeout( - closePromise.catch(() => undefined), - electronAppKillWaitMs - ); - - // SIGTERM asks Electron for a graceful quit, which the app can - // legitimately refuse — the unsaved-settings close guard cancels the - // quit while it waits for an answer. A process that survives here - // would outlive the test, hold its data dir, and time out the worker - // teardown, so escalate to SIGKILL. - if ( - childProcess.exitCode === null && - childProcess.signalCode === null - ) { - console.warn( - 'Electron app survived SIGTERM; escalating to SIGKILL' - ); - childProcess.kill('SIGKILL'); - await waitForPromiseWithTimeout( - closePromise.catch(() => undefined), - electronAppKillWaitMs - ); - } - } catch (error) { - console.warn('Failed to close Electron app cleanly:', error); - } + await closeElectronAppAndConfirmExit(app); } export async function closeElectronAppAndConfirmExit( @@ -621,26 +583,6 @@ export async function closeElectronAppAndConfirmExit( }); } -async function waitForPromiseWithTimeout( - promise: Promise, - timeoutMs: number -): Promise { - let timeoutId: NodeJS.Timeout | undefined; - - try { - return await Promise.race([ - promise.then(() => true), - new Promise((resolvePromise) => { - timeoutId = setTimeout(() => resolvePromise(false), timeoutMs); - }), - ]); - } finally { - if (timeoutId) { - clearTimeout(timeoutId); - } - } -} - function assertPackagedRendererBuildIsElectronSafe(): void { if (!existsSync(packagedRendererIndexPath)) { throw new Error( diff --git a/apps/electron-backend-e2e/src/legacy-playlist-migration.e2e.ts b/apps/electron-backend-e2e/src/legacy-playlist-migration.e2e.ts index 1e91bd8dc..2a9e44037 100644 --- a/apps/electron-backend-e2e/src/legacy-playlist-migration.e2e.ts +++ b/apps/electron-backend-e2e/src/legacy-playlist-migration.e2e.ts @@ -18,6 +18,7 @@ import { openSettingsSection, } from './electron-test-fixtures'; import { seedLegacyProfile, legacyPlaylists } from './legacy-profile-fixture'; +import { captureElectronProcess } from './electron-process-lifecycle'; import { applyTheme } from './theme-contrast'; interface StartupTestGlobals { @@ -94,6 +95,7 @@ require(${JSON.stringify(electronMainPath)});` ], env: buildElectronLaunchEnvironment(dataDir), }); + captureElectronProcess(app); const page = await app.firstWindow(); try { // The route is intentionally still empty while recovery is pending. diff --git a/apps/electron-backend-e2e/src/performance/electron-process-lifecycle.spec.ts b/apps/electron-backend-e2e/src/performance/electron-process-lifecycle.spec.ts index 15a6d003b..025c937bc 100644 --- a/apps/electron-backend-e2e/src/performance/electron-process-lifecycle.spec.ts +++ b/apps/electron-backend-e2e/src/performance/electron-process-lifecycle.spec.ts @@ -1,13 +1,28 @@ import assert from 'node:assert/strict'; import { EventEmitter } from 'node:events'; import { describe, it } from 'node:test'; +import { + closeElectronApp, + type LaunchedElectronApp, +} from '../electron-test-fixtures'; import { - closeElectronApplicationAndConfirmExit, + captureElectronProcess, + closeElectronApplicationAndConfirmExit as closeApplication, ElectronApplicationDisposalError, prepareElectronApplication, } from '../electron-process-lifecycle'; +// Fake children have no OS PID; exercise lifecycle decisions independently +// of the platform process-tree integration test. +const closeElectronApplicationAndConfirmExit: typeof closeApplication = ( + app, + options +) => + closeApplication(app, options, (child, signal) => { + child.kill(signal); + }); + class FakeChildProcess extends EventEmitter { exitCode: number | null = null; signalCode: NodeJS.Signals | null = null; @@ -25,6 +40,94 @@ class FakeChildProcess extends EventEmitter { } describe('Electron process lifecycle', () => { + it('rejects cleanup when no process was captured for that application', async () => { + await assert.rejects( + closeApplication( + { close: async () => assert.fail('exit cannot be confirmed') }, + { closeTimeoutMs: 1, exitTimeoutMs: 1 } + ), + /electron-process-handle-not-captured/ + ); + }); + + it('cleans the replacement application after a partial restart assignment', async () => { + const oldChild = new FakeChildProcess(); + const newChild = new FakeChildProcess(); + const oldApplication = { + close: async () => { + oldChild.exitCode = 0; + oldChild.emit('exit', 0, null); + }, + process: () => oldChild, + }; + const newApplication = { + close: async () => { + newChild.exitCode = 0; + newChild.emit('exit', 0, null); + }, + process: () => newChild, + }; + const app = { + electronApp: oldApplication, + } as unknown as LaunchedElectronApp; + captureElectronProcess(oldApplication); + captureElectronProcess(newApplication); + await closeElectronApp(app); + // Existing E2E callers replace the application/window fields only. + app.electronApp = + newApplication as unknown as LaunchedElectronApp['electronApp']; + + await closeElectronApp(app); + assert.equal(newChild.exitCode, 0); + }); + + it('confirms an already exited process after Playwright disposes its dispatcher', async () => { + const child = new FakeChildProcess(); + const app = { + electronApp: { + close: async () => { + assert.fail( + 'an exited application must not be closed again' + ); + }, + process: () => child, + }, + } as unknown as LaunchedElectronApp; + + captureElectronProcess(app.electronApp); + child.exitCode = 0; + app.electronApp.process = () => { + throw new TypeError( + "Cannot read properties of undefined (reading '_object')" + ); + }; + await closeElectronApp(app); + assert.equal(child.killCalls, 0); + }); + + it('does not return from public cleanup when close and termination both fail', async () => { + const child = new FakeChildProcess(); + child.kill = () => { + throw new Error('termination failed'); + }; + const app = { + electronApp: { + close: async () => { + throw new Error('CDP disconnected'); + }, + process: () => child, + }, + } as unknown as LaunchedElectronApp; + + captureElectronProcess(app.electronApp); + app.electronApp.process = () => { + assert.fail('cleanup must use the retained child process'); + }; + await assert.rejects( + closeElectronApp(app), + /electron-process-exit-unconfirmed/ + ); + }); it('closes and confirms exit when post-spawn launch preparation fails', async () => { const child = new FakeChildProcess(); const launchFailure = new Error('renderer readiness failed'); @@ -37,6 +140,7 @@ describe('Electron process lifecycle', () => { }, process: () => child, }; + captureElectronProcess(application); await assert.rejects( prepareElectronApplication({ @@ -56,6 +160,40 @@ describe('Electron process lifecycle', () => { assert.equal(child.exitCode, 0); }); + it('preserves preparation failure when Electron has already exited', async () => { + const child = new FakeChildProcess(); + const launchFailure = new Error('renderer closed before readiness'); + const application = { + close: async () => { + assert.fail('an exited application must not be closed again'); + }, + process: () => child, + }; + captureElectronProcess(application); + + await assert.rejects( + prepareElectronApplication({ + application, + dispose: (app) => + closeElectronApplicationAndConfirmExit(app, { + closeTimeoutMs: 10, + exitTimeoutMs: 10, + }), + prepare: async () => { + child.exitCode = 0; + application.process = () => { + assert.fail( + 'the Playwright dispatcher is already disposed' + ); + }; + throw launchFailure; + }, + }), + (error: unknown) => error === launchFailure + ); + assert.equal(child.killCalls, 0); + }); + it('fails when process exit remains unconfirmed after forced teardown', async () => { const child = new FakeChildProcess(); child.shouldExitOnKill = false; @@ -63,6 +201,7 @@ describe('Electron process lifecycle', () => { close: () => new Promise(() => undefined), process: () => child, }; + captureElectronProcess(application); await assert.rejects( closeElectronApplicationAndConfirmExit(application, { @@ -71,7 +210,7 @@ describe('Electron process lifecycle', () => { }), /electron-process-exit-unconfirmed/ ); - assert.equal(child.killCalls, 1); + assert.equal(child.killCalls, 2); }); it('preserves both preparation and unconfirmed-disposal failures', async () => { @@ -107,6 +246,7 @@ describe('Electron process lifecycle', () => { }, process: () => child, }; + captureElectronProcess(application); await Promise.race([ closeElectronApplicationAndConfirmExit(application, { @@ -129,6 +269,7 @@ describe('Electron process lifecycle', () => { close: () => new Promise(() => undefined), process: () => child, }; + captureElectronProcess(application); await closeElectronApplicationAndConfirmExit(application, { closeTimeoutMs: 1, @@ -137,4 +278,25 @@ describe('Electron process lifecycle', () => { assert.equal(child.killCalls, 1); assert.equal(child.signalCode, 'SIGTERM'); }); + + it('still observes exit after the first termination attempt throws', async () => { + const child = new FakeChildProcess(); + const signals: NodeJS.Signals[] = []; + const application = { + close: () => new Promise(() => undefined), + process: () => child, + }; + captureElectronProcess(application); + await closeApplication( + application, + { closeTimeoutMs: 1, exitTimeoutMs: 1 }, + (_child, signal) => { + signals.push(signal); + if (signal === 'SIGTERM') throw new Error('termination failed'); + child.kill(); + } + ); + assert.deepEqual(signals, ['SIGTERM', 'SIGKILL']); + assert.equal(child.signalCode, 'SIGTERM'); + }); }); diff --git a/apps/electron-backend-e2e/src/performance/electron-process-termination.spec.ts b/apps/electron-backend-e2e/src/performance/electron-process-termination.spec.ts new file mode 100644 index 000000000..ddb3cd95d --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/electron-process-termination.spec.ts @@ -0,0 +1,112 @@ +import assert from 'node:assert/strict'; +import { spawn, type execFileSync } from 'node:child_process'; +import { once } from 'node:events'; +import { describe, it } from 'node:test'; +import { terminateElectronProcess } from '../electron-process-termination'; + +describe('Electron forced termination', () => { + it('terminates the Windows shell and its Electron descendants together', () => { + let shellAlive = true; + let electronAlive = true; + const child = { + pid: 1234, + kill: () => { + shellAlive = false; + return true; + }, + }; + const run = ((file, args, options) => { + assert.equal(file, 'taskkill.exe'); + assert.deepEqual(args, ['/pid', '1234', '/T', '/F']); + assert.equal(options?.timeout, 5000); + shellAlive = false; + electronAlive = false; + return Buffer.alloc(0); + }) as typeof execFileSync; + + terminateElectronProcess(child, 'SIGTERM', 'win32', run); + + assert.equal(shellAlive, false); + assert.equal( + electronAlive, + false, + 'Electron must release the profile before relaunch' + ); + }); + + it('preserves signal escalation on Unix', () => { + const signals: NodeJS.Signals[] = []; + const child = { + pid: 1234, + kill: (signal: NodeJS.Signals) => { + signals.push(signal); + return true; + }, + }; + terminateElectronProcess(child, 'SIGTERM', 'linux'); + terminateElectronProcess(child, 'SIGKILL', 'darwin'); + assert.deepEqual(signals, ['SIGTERM', 'SIGKILL']); + }); + + it('does not fall back to killing only the shell when tree termination fails', () => { + const failure = new Error('taskkill failed'); + const child = { + pid: 1234, + kill: () => assert.fail('would orphan the Electron descendant'), + }; + const run = (() => { + throw failure; + }) as typeof execFileSync; + assert.throws( + () => terminateElectronProcess(child, 'SIGTERM', 'win32', run), + (error) => error === failure + ); + }); + + it( + 'reaps a real Windows shell child instead of orphaning its descendant', + { + skip: process.platform !== 'win32', + timeout: 15000, + }, + async () => { + // Match Playwright's Windows launch topology: cmd.exe -> application. + const shell = spawn( + `"${process.execPath}"`, + [ + '-e', + '"setInterval(() => {}, 1000); console.log(process.pid)"', + ], + { shell: true, stdio: ['ignore', 'pipe', 'pipe'] } + ); + try { + const [output] = await once(shell.stdout, 'data', { + signal: AbortSignal.timeout(5000), + }); + const descendantPid = Number(String(output).trim()); + assert.ok( + Number.isSafeInteger(descendantPid) && descendantPid > 0 + ); + assert.notEqual(descendantPid, shell.pid); + process.kill(descendantPid, 0); + + terminateElectronProcess(shell); + await assert.rejects( + async () => process.kill(descendantPid, 0), + { + code: 'ESRCH', + } + ); + } finally { + if (shell.exitCode === null && shell.signalCode === null) { + // The successful taskkill can precede Node's exit event. + try { + terminateElectronProcess(shell); + } catch { + /* already exited */ + } + } + } + } + ); +}); diff --git a/docs/development/electron-debugging.md b/docs/development/electron-debugging.md index bf3a282a7..ee1e35ba3 100644 --- a/docs/development/electron-debugging.md +++ b/docs/development/electron-debugging.md @@ -67,6 +67,30 @@ agent-browser connect ws://127.0.0.1:9222/devtools/page/ agent-browser screenshot /tmp/iptvnator-cdp.png ``` +## E2E process cleanup and packaged diagnostics + +Playwright launches Electron through `cmd.exe` on Windows. If graceful E2E +shutdown times out, terminate that process tree with `taskkill /T /F`; killing +only `electronApp.process()` can leave Electron holding the temporary profile +and make the next launch exit on the single-instance lock. The E2E workflow +checks this against a real Windows shell and child process. Test cleanup must +target only its own launch PID, never all Electron or IPTVnator processes. +Cleanup confirms process exit even if Playwright's close promise fails or +never settles. If termination fails, it retries and then throws instead of +allowing a relaunch against a potentially locked profile. +Capture the Node child-process handle immediately after launch and retain it +in a WeakMap keyed by the Electron application for cleanup and preparation +failures. This binding follows the application when restart callers replace +only the application/window fields on their fixture. After the last window +closes on Linux, +Electron can exit before cleanup begins; Playwright disposes its dispatcher, +so calling `electronApp.process()` at that point can throw even after a clean +exit. The retained handle still provides the actual exit code and signal. + +The Linux portable build uploads `packaged-frame-copy-smoke` reports and traces +even when the smoke fails. Check the paused-frame screenshot and trace before +classifying a zero rendered-frame signal as an infrastructure flake. + ## Main-process ownership The entry point is `apps/electron-backend/src/main.ts`; it bootstraps the database, diff --git a/tools/packaging/snap-workflow-policy.test-helpers.mjs b/tools/packaging/snap-workflow-policy.test-helpers.mjs index 3377a642f..1a922300b 100644 --- a/tools/packaging/snap-workflow-policy.test-helpers.mjs +++ b/tools/packaging/snap-workflow-policy.test-helpers.mjs @@ -13,6 +13,7 @@ const PUBLISH_ACTION_ALLOWLIST = Object.freeze([ PINNED_UPLOAD_ARTIFACT_ACTION, ]); const BUILD_ACTION_ALLOWLIST = Object.freeze([ + PINNED_UPLOAD_ARTIFACT_ACTION, 'actions/cache/restore@v6', 'actions/cache/save@v6', 'actions/cache@v6',