fix(release): provision Windows bundle tools on cache misses
This commit is contained in:
5
.github/actions/setup-pm/README.md
vendored
5
.github/actions/setup-pm/README.md
vendored
@@ -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.
|
||||
|
||||
44
.github/workflows/desktop-bundled-release.yml
vendored
44
.github/workflows/desktop-bundled-release.yml
vendored
@@ -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"
|
||||
|
||||
|
||||
31
.github/workflows/pm-toolchain-cache-cleanup.yml
vendored
Normal file
31
.github/workflows/pm-toolchain-cache-cleanup.yml
vendored
Normal 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"
|
||||
26
.github/workflows/pm-toolchain.yml
vendored
26
.github/workflows/pm-toolchain.yml
vendored
@@ -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
|
||||
|
||||
51
apps/desktop/scripts/windows-bundle-tools.mjs
Normal file
51
apps/desktop/scripts/windows-bundle-tools.mjs
Normal 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
|
||||
}
|
||||
83
apps/desktop/scripts/windows-bundle-tools.test.mjs
Normal file
83
apps/desktop/scripts/windows-bundle-tools.test.mjs
Normal 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
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
63
scripts/ci/cleanup_pm_toolchain_caches.py
Normal file
63
scripts/ci/cleanup_pm_toolchain_caches.py
Normal 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()
|
||||
@@ -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}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
52
tests/ci/test_cleanup_pm_toolchain_caches.py
Normal file
52
tests/ci/test_cleanup_pm_toolchain_caches.py
Normal 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")]
|
||||
Reference in New Issue
Block a user