fix(release): provision Windows bundle tools on cache misses

This commit is contained in:
ethernet
2026-09-08 14:32:30 -04:00
parent 0d9d00426f
commit d36562ac9f
11 changed files with 385 additions and 144 deletions

View File

@@ -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.

View File

@@ -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"

View File

@@ -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"

View File

@@ -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

View File

@@ -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
}

View File

@@ -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
}
})

View File

@@ -6,8 +6,8 @@
// (Store-<name>-<fileVersion>-win-<arch>.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)

View File

@@ -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()

View File

@@ -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@<ver>/` — there is NO `winCodeSign`
// subdir. The Windows Kits bundle extracts to
// win-codesign@<ver>/windows-kits-bundle-10_0_26100_0-<hash>/ with the
// HOST tools (signtool.exe + makeappx.exe) in its x64/ subdir. Legacy
// winCodeSign-2.6.0 used windows-10/<arch>/; the old nuget layout
// bin/<ver>/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 <cacheDir>/win-codesign@<ver>/windows-kits-bundle-10_0_26100_0-<hash>/,
// 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/<arch>/ under the toolset
// dir; the old nuget layout bin/<ver>/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/<ver>/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}

View File

@@ -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')
}

View File

@@ -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")]