diff --git a/.github/actions/setup-pm/README.md b/.github/actions/setup-pm/README.md index 3bedd1ffbd..84dbbfc9a5 100644 --- a/.github/actions/setup-pm/README.md +++ b/.github/actions/setup-pm/README.md @@ -72,4 +72,7 @@ Use the version outputs in installed-tree cache keys instead of repeating pins. `.github/workflows/pm-toolchain.yml` exercises cold setup and a separate warm runner for Linux, macOS and Windows on both architectures. Its optional cache -suffix keeps that proof isolated from ordinary build caches. +suffix isolates cache lookup, not the repository's storage budget. The trusted +`pm-toolchain-cache-cleanup.yml` completion workflow deletes only that run's +smoke keys after all cache saves finish, including failed or cancelled runs. +It must be on the default branch for GitHub to run it. diff --git a/.github/workflows/desktop-bundled-release.yml b/.github/workflows/desktop-bundled-release.yml index 3aa20932f2..1155eee747 100644 --- a/.github/workflows/desktop-bundled-release.yml +++ b/.github/workflows/desktop-bundled-release.yml @@ -880,11 +880,16 @@ jobs: fetch-tags: true - name: Set up the locked build toolchain - id: pm uses: ./.github/actions/setup-pm with: - toolchain: node - cache-node: false + toolchain: all + cache-python: false + + - name: Install the locked Windows bundle tooling + uses: ./.github/actions/retry + with: + # No Electron/native postinstalls: only the builder's SDK downloader. + command: npm ci --workspace apps/desktop --include-workspace-root --include=dev --ignore-scripts --no-audit --no-fund - name: Resolve toolchain cache key id: toolchain @@ -899,13 +904,9 @@ jobs: console.log(`builder=${eb}`) ' >> "$GITHUB_OUTPUT" - # makeappx + signtool live in the winCodeSign toolset that - # electron-builder downloads into its cache during the build legs; the - # publish jobs run on a fresh runner, so restore the same eb2 cache - # the win32 legs saved. The path list MUST match the build legs' - # byte-for-byte — actions/cache derives the version hash from the paths - # input, so a shorter list computes a different version and the restore - # misses ("Cache not found") even with the identical key. + # Cache reuse is optional. Bundle scripts provision the pinned SDK + # and signing dependencies through electron-builder on a cache miss. + # Keep the path list identical to the build legs for warm reuse. - name: Resolve electron's default download cache path shell: bash run: | @@ -1025,11 +1026,16 @@ jobs: fetch-tags: true - name: Set up the locked build toolchain - id: pm uses: ./.github/actions/setup-pm with: - toolchain: node - cache-node: false + toolchain: all + cache-python: false + + - name: Install the locked Windows bundle tooling + uses: ./.github/actions/retry + with: + # No Electron/native postinstalls: only the builder's SDK downloader. + command: npm ci --workspace apps/desktop --include-workspace-root --include=dev --ignore-scripts --no-audit --no-fund - name: Resolve toolchain cache key id: toolchain @@ -1044,8 +1050,8 @@ jobs: console.log(`builder=${eb}`) ' >> "$GITHUB_OUTPUT" - # makeappx for the Store bundle lives in the same winCodeSign toolset - # the win32 legs downloaded — restore the identical eb2 cache. + # Reuse the build cache when available. The bundle script also works + # cold by provisioning the same SDK through the pinned builder. - name: Resolve electron's default download cache path shell: bash run: | @@ -1077,9 +1083,11 @@ jobs: id: storebundle shell: bash run: | - # Prints the absolute bundle path on stdout (the machine-readable - # result); logs go to stderr. - bundle="$(node scripts/bundle-store-msixbundle.mjs --tag "$HERMES_PAYLOAD_TAG")" + # The SDK downloader logs to stdout; the path has its own output. + result="$RUNNER_TEMP/store-bundle-path" + node scripts/bundle-store-msixbundle.mjs --tag "$HERMES_PAYLOAD_TAG" --output-file "$result" + bundle="$(< "$result")" + test -f "$bundle" echo "bundle=$bundle" >> "$GITHUB_OUTPUT" echo "Store bundle: $bundle" diff --git a/.github/workflows/pm-toolchain-cache-cleanup.yml b/.github/workflows/pm-toolchain-cache-cleanup.yml new file mode 100644 index 0000000000..1c3be9422a --- /dev/null +++ b/.github/workflows/pm-toolchain-cache-cleanup.yml @@ -0,0 +1,31 @@ +name: Clean PM toolchain smoke caches + +# workflow_run uses default-branch code, not the PR's checkout. Cleanup also +# runs for failed/cancelled smoke workflows after all cache post steps finish. +on: + workflow_run: + workflows: [PM Toolchain] + types: [completed] + +permissions: + contents: read + actions: write + +concurrency: + group: pm-smoke-cleanup-${{ github.event.workflow_run.id }} + cancel-in-progress: false + +jobs: + cleanup: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + - name: Remove only this completed run's isolated caches + env: + GH_TOKEN: ${{ github.token }} + SMOKE_RUN_ID: ${{ github.event.workflow_run.id }} + run: python3 scripts/ci/cleanup_pm_toolchain_caches.py "$SMOKE_RUN_ID" diff --git a/.github/workflows/pm-toolchain.yml b/.github/workflows/pm-toolchain.yml index e1dc43a0fe..346bdd26a8 100644 --- a/.github/workflows/pm-toolchain.yml +++ b/.github/workflows/pm-toolchain.yml @@ -38,7 +38,7 @@ jobs: extras: '["dev"]' prune-python-cache: true cache-suffix: smoke-prune-${{ github.run_id }}-${{ github.run_attempt }} - - run: python -c 'import pytest; print(pytest.__version__)' + - run: scripts/run_tests.sh tests/ci/test_cleanup_pm_toolchain_caches.py -j 1 -q build-consumers: runs-on: ubuntu-24.04 timeout-minutes: 20 @@ -66,3 +66,27 @@ jobs: npm run payload --workspace apps/desktop -- --help node scripts/generate-icons.mjs node scripts/generate-icons.mjs --check + + windows-bundle-tools: + name: Cold Windows SDK (${{ matrix.runner }}) + runs-on: ${{ matrix.runner }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + runner: [windows-2025, windows-11-arm] + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: ./.github/actions/setup-pm + with: + toolchain: all + cache: false + cache-python: false + cache-node: false + - name: Install the locked bundle tooling without native postinstalls + run: npm ci --workspace apps/desktop --workspace tests-js --include-workspace-root --include=dev --ignore-scripts --no-audit --no-fund + - name: Provision from an empty cache and execute the SDK + working-directory: apps/desktop + run: node ../../node_modules/vitest/vitest.mjs run --project electron scripts/windows-bundle-tools.test.mjs scripts/msix-shared.test.mjs scripts/sign-msix.test.mjs scripts/batch-sign-binaries.test.mjs diff --git a/apps/desktop/scripts/windows-bundle-tools.mjs b/apps/desktop/scripts/windows-bundle-tools.mjs new file mode 100644 index 0000000000..a05b10d28b --- /dev/null +++ b/apps/desktop/scripts/windows-bundle-tools.mjs @@ -0,0 +1,51 @@ +// Standalone bundle jobs need the same tools as electron-builder, even when +// GitHub evicts the build cache before the publishing job starts. +import fs from 'node:fs' +import { createRequire } from 'node:module' +import path from 'node:path' +import { pathToFileURL } from 'node:url' + +const require = createRequire(import.meta.url) + +async function loadBuilderTools() { + // app-builder-lib exports only its entry and ./internal. Resolve the + // installed, lock-pinned package before loading its toolset implementation. + const entry = pathToFileURL(require.resolve('app-builder-lib')) + return import(new URL('./toolsets/winCodeSign.js', entry).href) +} + +export async function ensureWindowsBundleTools({ + signing = false, + config = require('../electron-builder.config.cjs'), + resourcesDir = path.resolve(import.meta.dirname, '..', config.directories?.buildResources || 'build'), + load = loadBuilderTools, +} = {}) { + const builder = await load() + const configured = config.toolsets?.winCodeSign + const { kit } = await builder.getWindowsKitsBundle({ winCodeSign: configured, resourcesDir }) + const result = { + makeappx: path.join(kit, 'makeappx.exe'), + signtool: path.join(kit, 'signtool.exe'), + dlib: null, + dotnetRoot: null, + } + if (signing) { + if (configured != null && typeof configured === 'object') { + // This is electron-builder's custom-toolset contract: the owner + // provides the dlib alongside the kit and manages its runtime. + result.dlib = path.join(kit, 'Azure.CodeSigning.Dlib.dll') + } else { + const version = configured == null || configured === 'latest' ? builder.WIN_CODESIGN_LATEST : configured + const ats = await builder.getAtsBundleDir(version) + result.dlib = path.join(ats, path.basename(kit), 'Azure.CodeSigning.Dlib.dll') + result.dotnetRoot = await builder.getDotnetRuntimeDir(version) + } + } + const files = [result.makeappx, result.signtool] + if (signing) files.push(result.dlib) + if (result.dotnetRoot) files.push(path.join(result.dotnetRoot, 'dotnet.exe')) + for (const file of files) { + if (!fs.statSync(file).isFile()) throw new Error(`Windows bundle tool is not a file: ${file}`) + } + return result +} diff --git a/apps/desktop/scripts/windows-bundle-tools.test.mjs b/apps/desktop/scripts/windows-bundle-tools.test.mjs new file mode 100644 index 0000000000..39d94d5d3f --- /dev/null +++ b/apps/desktop/scripts/windows-bundle-tools.test.mjs @@ -0,0 +1,83 @@ +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, expect, test, vi } from 'vitest' +import { ensureWindowsBundleTools } from './windows-bundle-tools.mjs' + +const directories = [] +afterEach(() => { + for (const directory of directories.splice(0)) fs.rmSync(directory, { recursive: true, force: true }) +}) + +// The adapter models the builder's installer, not an existing cache. The +// native smoke runs its real downloader and all three executable tools. +test('both bundle modes provision pinned tools rather than search a prefilled cache', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'bundle-tools-')) + directories.push(root) + const calls = [] + const materialize = (name, files) => { + const directory = path.join(root, name) + for (const file of files) { + const destination = path.join(directory, file) + fs.mkdirSync(path.dirname(destination), { recursive: true }) + fs.writeFileSync(destination, 'test installer output') + } + return directory + } + const builder = { + WIN_CODESIGN_LATEST: 'owned-by-builder', + getWindowsKitsBundle: async ({ winCodeSign, resourcesDir }) => { + calls.push(['kit', winCodeSign, resourcesDir]) + return { kit: materialize('kit/x64', ['makeappx.exe', 'signtool.exe']) } + }, + getAtsBundleDir: async version => { + calls.push(['ats', version]) + return materialize('ats', ['x64/Azure.CodeSigning.Dlib.dll']) + }, + getDotnetRuntimeDir: async version => { + calls.push(['dotnet', version]) + return materialize('dotnet', ['dotnet.exe']) + }, + } + const load = async () => builder + const resourcesDir = path.join(root, 'resources') + const store = await ensureWindowsBundleTools({ load, config: {}, resourcesDir }) + expect(calls).toEqual([['kit', undefined, resourcesDir]]) + expect(store.dlib).toBeNull() + expect(store.dotnetRoot).toBeNull() + expect(fs.existsSync(store.makeappx)).toBe(true) + const signed = await ensureWindowsBundleTools({ load, config: {}, resourcesDir, signing: true }) + expect(calls.slice(1)).toEqual([ + ['kit', undefined, resourcesDir], ['ats', builder.WIN_CODESIGN_LATEST], ['dotnet', builder.WIN_CODESIGN_LATEST], + ]) + expect(path.basename(signed.dlib)).toBe('Azure.CodeSigning.Dlib.dll') + expect(fs.existsSync(path.join(signed.dotnetRoot, 'dotnet.exe'))).toBe(true) + const failed = vi.fn().mockRejectedValue(new Error('pinned download failed')) + await expect(ensureWindowsBundleTools({ load: async () => ({ ...builder, getWindowsKitsBundle: failed }), config: {}, resourcesDir })).rejects.toThrow('pinned download failed') +}) + +test.runIf(process.platform === 'win32')('a native cold install produces executable SDK tools and reuses them warm', { timeout: 240_000 }, async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'bundle-tools-native-')) + directories.push(root) + const previous = process.env.ELECTRON_BUILDER_CACHE + process.env.ELECTRON_BUILDER_CACHE = path.join(root, 'empty-cache') + try { + const tools = await ensureWindowsBundleTools({ signing: true }) + for (const file of [tools.makeappx, tools.signtool, tools.dlib, tools.dotnetRoot]) { + expect(path.relative(root, file).startsWith('..')).toBe(false) + } + // Verify an actual SDK binary, not a dummy file or a help-only exit. + const verified = execFileSync(tools.signtool, ['verify', '/pa', tools.makeappx], { encoding: 'utf8', timeout: 60_000 }) + expect(verified).toContain('Successfully verified') + const runtime = execFileSync(path.join(tools.dotnetRoot, 'dotnet.exe'), ['--list-runtimes'], { + env: { ...process.env, DOTNET_ROOT: tools.dotnetRoot }, encoding: 'utf8', timeout: 60_000, + }) + expect(runtime).toContain('Microsoft.NETCore.App') + const warm = await ensureWindowsBundleTools({ signing: true }) + expect(warm).toEqual(tools) + } finally { + if (previous === undefined) delete process.env.ELECTRON_BUILDER_CACHE + else process.env.ELECTRON_BUILDER_CACHE = previous + } +}) diff --git a/scripts/bundle-store-msixbundle.mjs b/scripts/bundle-store-msixbundle.mjs index 9b358df701..78545131b7 100644 --- a/scripts/bundle-store-msixbundle.mjs +++ b/scripts/bundle-store-msixbundle.mjs @@ -6,8 +6,8 @@ // (Store---win-.msix, built with // HERMES_DESKTOP_VARIANT=store / the Partner Center identity). This script // bundles the x64 + arm64 packages into ONE universal Store .msixbundle for -// the Windows Store submission, and prints the bundle's absolute path on -// stdout (the workflow captures it for `msstore publish`). +// the Windows Store submission. --output-file writes its absolute path for +// callers, independently of the installer's download/progress logs. // // The bundle is deliberately left UNSIGNED: the Store re-signs the package // with the Microsoft Store certificate on ingestion (same posture as the @@ -20,7 +20,8 @@ import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' -import { appIdentity, resolveWinSdkTools } from './msix-shared.mjs' +import { appIdentity } from './msix-shared.mjs' +import { ensureWindowsBundleTools } from '../apps/desktop/scripts/windows-bundle-tools.mjs' const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') @@ -73,12 +74,14 @@ fs.copyFileSync(x64, path.join(staging, path.basename(x64))) fs.copyFileSync(arm64, path.join(staging, path.basename(arm64))) const bundle = path.join(releaseDir, `Store-${name}-${version}-win.msixbundle`) -const makeappx = path.join(resolveWinSdkTools(), 'makeappx.exe') +const { makeappx } = await ensureWindowsBundleTools() if (fs.existsSync(bundle)) fs.rmSync(bundle, { force: true }) execFileSync(makeappx, ['bundle', '/o', '/bv', version, '/d', staging, '/p', bundle], { - stdio: ['ignore', 'ignore', 'inherit'] // stdout stays clean: the path is the machine-readable result + stdio: 'inherit' }) fs.rmSync(staging, { recursive: true, force: true }) -// stdout = the absolute bundle path, the ONLY thing the workflow reads back. +// Download logs can share stdout. The explicit output file is the machine contract. +const outputFile = flagValue('--output-file') +if (outputFile) fs.writeFileSync(outputFile, bundle, 'utf8') console.log(bundle) diff --git a/scripts/ci/cleanup_pm_toolchain_caches.py b/scripts/ci/cleanup_pm_toolchain_caches.py new file mode 100644 index 0000000000..6c276ef3be --- /dev/null +++ b/scripts/ci/cleanup_pm_toolchain_caches.py @@ -0,0 +1,63 @@ +"""Remove only run-isolated caches after a PM Toolchain smoke run completes.""" +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess + + +def cleanup_run_caches(run_id: str, request, *, page_size: int = 100) -> list[int]: + if not re.fullmatch(r"[1-9][0-9]*", run_id): + raise ValueError("invalid run ID") + run = request("GET", f"actions/runs/{run_id}") + if run["status"] != "completed" or run["path"] != ".github/workflows/pm-toolchain.yml": + raise ValueError("cleanup requires a completed PM Toolchain run") + pattern = re.compile( + rf"^(?:setup-pm-|node-cache-).*-smoke(?:-prune|-consumers)?-{run_id}-[1-9][0-9]*$" + ) + + def collect(): + # Finish pagination before deleting: deletion shifts the next page. + rows = [] + page = 1 + while True: + data = request("GET", f"actions/caches?per_page={page_size}&page={page}") + rows.extend(data["actions_caches"]) + if len(rows) >= data["total_count"]: + return [row for row in rows if pattern.fullmatch(row["key"])] + if not data["actions_caches"]: + raise RuntimeError("cache inventory ended before its declared total") + page += 1 + + selected = collect() + for row in selected: + request("DELETE", f"actions/caches/{row['id']}") + print(f"deleted smoke cache {row['id']}: {row['key']}") + remaining = collect() + if remaining: + raise RuntimeError(f"smoke caches remain after cleanup: {[row['id'] for row in remaining]}") + return [row["id"] for row in selected] + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("run_id") + args = parser.parse_args() + repository = os.environ["GITHUB_REPOSITORY"] + if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository): + raise ValueError("invalid repository") + def request(method, path): + result = subprocess.run( + ["gh", "api", "--method", method, f"repos/{repository}/{path}"], + check=True, capture_output=True, text=True, encoding="utf-8", timeout=90, + ) + return json.loads(result.stdout) if result.stdout.strip() else None + + removed = cleanup_run_caches(args.run_id, request) + print(f"removed {len(removed)} run-isolated caches") + + +if __name__ == "__main__": + main() diff --git a/scripts/msix-shared.mjs b/scripts/msix-shared.mjs index c19b6b932e..1999870629 100644 --- a/scripts/msix-shared.mjs +++ b/scripts/msix-shared.mjs @@ -50,76 +50,6 @@ export function contentTypeFor(filename) { return undefined } -// ── makeappx / signtool resolution (SDK BuildTools nuget) ───────────────── -// electron-builder downloads Microsoft.Windows.SDK.BuildTools into its -// winCodeSign cache; the bundle jobs use the SAME pin so makeappx/signtool -// match the builder's. Shared by stage-msixbundle.mjs (out-of-store feed) and -// bundle-store-msixbundle.mjs (Store-submission bundle) — one resolver. -export function resolveWinSdkTools() { - // electron-builder downloads its signing toolsets into the cache root - // (ELECTRON_BUILDER_CACHE on CI, %LOCALAPPDATA%/electron-builder/Cache - // by default) under `win-codesign@/` — there is NO `winCodeSign` - // subdir. The Windows Kits bundle extracts to - // win-codesign@/windows-kits-bundle-10_0_26100_0-/ with the - // HOST tools (signtool.exe + makeappx.exe) in its x64/ subdir. Legacy - // winCodeSign-2.6.0 used windows-10//; the old nuget layout - // bin//x64/ is long gone. - const roots = [ - process.env.ELECTRON_BUILDER_CACHE || '', - path.join(process.env.LOCALAPPDATA || '', 'electron-builder', 'Cache'), - path.join(process.env.LOCALAPPDATA || '', 'electron-builder', 'cache'), - path.join(process.env.USERPROFILE || '', 'AppData', 'Local', 'electron-builder', 'Cache') - ] - for (const root of roots) { - if (!root || !fs.existsSync(root)) continue - for (const entry of fs.readdirSync(root)) { - const dir = path.join(root, entry) - if (!fs.statSync(dir).isDirectory()) continue - // Modern electron-builder (win-codesign@1.x): the Windows Kits bundle - // extracts to /win-codesign@/windows-kits-bundle-10_0_26100_0-/, - // with the HOST tools (signtool.exe + makeappx.exe) directly in the - // x64/ subdir of the bundle folder — two levels under the cache root. - // Legacy winCodeSign-2.6.0 used windows-10// under the toolset - // dir; the old nuget layout bin//x64/ is gone. Check every dir - // two levels down that carries a makeappx.exe + signtool.exe. - const toolDirs = [] - for (const sub of fs.readdirSync(dir)) { - const subDir = path.join(dir, sub) - if (!fs.statSync(subDir).isDirectory()) continue - for (const arch of ['x64']) { - const x64 = path.join(subDir, arch) - if (fs.existsSync(path.join(x64, 'makeappx.exe')) && fs.existsSync(path.join(x64, 'signtool.exe'))) { - toolDirs.push(x64) - } - } - // Legacy: windows-10/x64 (winCodeSign-2.6.0) - const win10 = path.join(subDir, 'windows-10', 'x64') - if (fs.existsSync(path.join(win10, 'makeappx.exe')) && fs.existsSync(path.join(win10, 'signtool.exe'))) { - toolDirs.push(win10) - } - // Legacy nuget: bin//x64 - const binDir = path.join(subDir, 'bin') - if (fs.existsSync(binDir)) { - for (const bsub of fs.readdirSync(binDir)) { - const x64 = path.join(binDir, bsub, 'x64') - if (fs.existsSync(path.join(x64, 'makeappx.exe')) && fs.existsSync(path.join(x64, 'signtool.exe'))) { - toolDirs.push(x64) - } - } - } - } - // First root with a usable kit wins — the configured - // ELECTRON_BUILDER_CACHE must beat any stray default cache. - if (toolDirs.length > 0) { - toolDirs.sort() - return toolDirs[toolDirs.length - 1] - } - } - } - console.error('[resolveWinSdkTools] no makeappx/signtool found under electron-builder winCodeSign cache') - process.exit(1) -} - /** * @param {unknown} value any value to XML-escape * @returns {string} diff --git a/scripts/stage-msixbundle.mjs b/scripts/stage-msixbundle.mjs index e0fe9408fc..61a8fe97b5 100644 --- a/scripts/stage-msixbundle.mjs +++ b/scripts/stage-msixbundle.mjs @@ -28,8 +28,8 @@ import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' -import { appIdentity, buildAppInstaller, resolveWinSdkTools } from './msix-shared.mjs' -import { resolveDotnetRuntimeDir, resolveTrustedSigningDlib } from '../apps/desktop/scripts/batch-sign-binaries.mjs' +import { appIdentity, buildAppInstaller } from './msix-shared.mjs' +import { ensureWindowsBundleTools } from '../apps/desktop/scripts/windows-bundle-tools.mjs' const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') @@ -86,9 +86,8 @@ function bundleFile() { return path.join(releaseDir, `${name}-${version}-win.msixbundle`) } -const winSdk = resolveWinSdkTools() -const makeappx = path.join(winSdk, 'makeappx.exe') -const signtool = path.join(winSdk, 'signtool.exe') +const signing = Boolean(process.env.AZURE_SIGN_ENDPOINT && process.env.AZURE_SIGN_ACCOUNT && process.env.AZURE_SIGN_PROFILE) +const { makeappx, signtool, dlib, dotnetRoot } = await ensureWindowsBundleTools({ signing }) // ── 1. bundle ────────────────────────────────────────────────────────────── const x64 = msixFile('x64') @@ -112,51 +111,45 @@ fs.copyFileSync(arm64, path.join(bundleStaging, path.basename(arm64))) if (fs.existsSync(bundle)) fs.rmSync(bundle, { force: true }) execFileSync(makeappx, ['bundle', '/o', '/bv', version, '/d', bundleStaging, '/p', bundle], { stdio: 'inherit' }) -// Sign ONLY the bundle envelope; the inner .msix keep their build-leg -// signatures. Runs only when the Azure vars are present (fork without them -// ships unsigned — same posture as the build legs). -if (process.env.AZURE_SIGN_ENDPOINT && process.env.AZURE_SIGN_ACCOUNT && process.env.AZURE_SIGN_PROFILE) { - const dlib = resolveTrustedSigningDlib() - if (dlib) { - const metaPath = path.join(releaseDir, 'msixbundle-sign.json') - fs.writeFileSync(metaPath, JSON.stringify({ - Endpoint: process.env.AZURE_SIGN_ENDPOINT, - CodeSigningAccountName: process.env.AZURE_SIGN_ACCOUNT, - CertificateProfileName: process.env.AZURE_SIGN_PROFILE - })) - const signEnv = { ...process.env } - const dotnetRoot = resolveDotnetRuntimeDir() - if (dotnetRoot) signEnv.DOTNET_ROOT = dotnetRoot - // MSIX/appx packages REQUIRE a timestamp — signtool silently exits 3 on - // a .msixbundle sign without /tr (untimestamped appx is invalid). And - // the /tr URL must be one the ATS dlib can speak: the dlib handles the - // RFC3161 exchange itself (@url: form) and cannot parse a third-party - // server's response ("no content extracted" with digicert). The only - // known-working timestamp server for the dlib is Microsoft's own - // timestamp.acs.microsoft.com (electron-builder's default, and what the - // build legs' .msix sign uses). acs is intermittently flaky, so retry - // the whole sign — a retried sign beats a failed bundle, and signtool - // replaces the signature on re-sign so a retry is safe. - const sign = () => - execFileSync(signtool, [ - 'sign', '/fd', 'SHA256', '/td', 'SHA256', '/tr', 'http://timestamp.acs.microsoft.com', - '/dlib', dlib, '/dmdf', metaPath, bundle - ], { stdio: 'inherit', env: signEnv }) - let attempt = 0 - for (;;) { - try { - sign() - break - } catch (err) { - attempt += 1 - if (attempt >= 3) throw err - console.warn(`[stage-msixbundle] sign attempt ${attempt} failed, retrying…`) - } +// Signtool signs the bundle and refreshes its inner package signatures. +// The source .msix files stay unchanged. Without Azure configuration this +// remains an unsigned local build, as on the build legs. +if (signing) { + const metaPath = path.join(releaseDir, 'msixbundle-sign.json') + fs.writeFileSync(metaPath, JSON.stringify({ + Endpoint: process.env.AZURE_SIGN_ENDPOINT, + CodeSigningAccountName: process.env.AZURE_SIGN_ACCOUNT, + CertificateProfileName: process.env.AZURE_SIGN_PROFILE + })) + const signEnv = { ...process.env } + if (dotnetRoot) signEnv.DOTNET_ROOT = dotnetRoot + // MSIX/appx packages REQUIRE a timestamp — signtool silently exits 3 on + // a .msixbundle sign without /tr (untimestamped appx is invalid). And + // the /tr URL must be one the ATS dlib can speak: the dlib handles the + // RFC3161 exchange itself (@url: form) and cannot parse a third-party + // server's response ("no content extracted" with digicert). The only + // known-working timestamp server for the dlib is Microsoft's own + // timestamp.acs.microsoft.com (electron-builder's default, and what the + // build legs' .msix sign uses). acs is intermittently flaky, so retry + // the whole sign — a retried sign beats a failed bundle, and signtool + // replaces the signature on re-sign so a retry is safe. + const sign = () => + execFileSync(signtool, [ + 'sign', '/fd', 'SHA256', '/td', 'SHA256', '/tr', 'http://timestamp.acs.microsoft.com', + '/dlib', dlib, '/dmdf', metaPath, bundle + ], { stdio: 'inherit', env: signEnv }) + let attempt = 0 + for (;;) { + try { + sign() + break + } catch (err) { + attempt += 1 + if (attempt >= 3) throw err + console.warn(`[stage-msixbundle] sign attempt ${attempt} failed, retrying…`) } - execFileSync(signtool, ['verify', '/pa', bundle], { stdio: 'inherit' }) - } else { - console.warn('[stage-msixbundle] Azure Trusted Signing dlib not found — bundle will be UNSIGNED') } + execFileSync(signtool, ['verify', '/pa', bundle], { stdio: 'inherit' }) } else { console.warn('[stage-msixbundle] AZURE_SIGN_* not set — bundle will be UNSIGNED') } diff --git a/tests/ci/test_cleanup_pm_toolchain_caches.py b/tests/ci/test_cleanup_pm_toolchain_caches.py new file mode 100644 index 0000000000..dd39ddfeff --- /dev/null +++ b/tests/ci/test_cleanup_pm_toolchain_caches.py @@ -0,0 +1,52 @@ +"""Smoke cache cleanup is bound to completed PM runs and exact key namespaces.""" +from __future__ import annotations + +import pytest + +from scripts.ci.cleanup_pm_toolchain_caches import cleanup_run_caches + + +def test_cleanup_collects_all_pages_then_deletes_only_its_run(): + rows = [ + {"id": 1, "key": "setup-pm-tools-x64-smoke-42-1"}, + {"id": 2, "key": "node-cache-Windows-x64-smoke-42-2"}, + {"id": 3, "key": "setup-pm-uv-x64-smoke-prune-42-1"}, + {"id": 4, "key": "setup-pm-tools-x64-smoke-consumers-42-1"}, + {"id": 5, "key": "setup-pm-tools-x64-smoke-420-1"}, + {"id": 6, "key": "node-cache-Windows-x64-normal"}, + {"id": 7, "key": "payload-signatures-smoke-42-1"}, + ] + removed = [] + pages_read = [] + + def request(method, path): + if path == "actions/runs/42": + return {"status": "completed", "path": ".github/workflows/pm-toolchain.yml"} + if method == "DELETE": + assert pages_read[:4] == [1, 2, 3, 4] + removed.append(int(path.rsplit("/", 1)[-1])) + rows[:] = [row for row in rows if row["id"] != removed[-1]] + return None + page = int(path.rsplit("page=", 1)[-1]) + pages_read.append(page) + return {"total_count": len(rows), "actions_caches": rows[(page - 1) * 2:page * 2]} + + assert cleanup_run_caches("42", request, page_size=2) == [1, 2, 3, 4] + assert removed == [1, 2, 3, 4] + assert {row["id"] for row in rows} == {5, 6, 7} + + +@pytest.mark.parametrize("status,path", [ + ("in_progress", ".github/workflows/pm-toolchain.yml"), + ("completed", ".github/workflows/desktop-bundled-release.yml"), +]) +def test_cleanup_refuses_active_or_unrelated_runs(status, path): + calls = [] + + def request(method, route): + calls.append((method, route)) + return {"status": status, "path": path} + + with pytest.raises(ValueError, match="completed PM Toolchain run"): + cleanup_run_caches("42", request) + assert calls == [("GET", "actions/runs/42")]