From c55a3f21268124c3b251547a32226f8d7b332976 Mon Sep 17 00:00:00 2001 From: liugddx Date: Sun, 23 Aug 2026 14:09:55 +0800 Subject: [PATCH] ci(windows): gate packaged sandbox lifecycle evidence Generated-by: Codex --- .github/workflows/release-windows-check.yml | 27 +++- package-lock.json | 1 + package.json | 3 +- scripts/ci-test-plan.mjs | 2 + scripts/ci-test-plan.test.mjs | 31 ++++ scripts/verify-windows-harness.test.mjs | 36 +++++ scripts/verify-windows-x64.mjs | 18 +++ scripts/windows-package-source-closure.mjs | 136 ++++++++++++++++++ .../windows-package-source-closure.test.mjs | 53 +++++++ 9 files changed, 305 insertions(+), 2 deletions(-) create mode 100644 scripts/windows-package-source-closure.mjs create mode 100644 scripts/windows-package-source-closure.test.mjs diff --git a/.github/workflows/release-windows-check.yml b/.github/workflows/release-windows-check.yml index c8d0c3ae1f..37d91ddd96 100644 --- a/.github/workflows/release-windows-check.yml +++ b/.github/workflows/release-windows-check.yml @@ -33,7 +33,6 @@ on: - 'apps/desktop/electron-builder.config.mjs' - 'apps/desktop/package.json' - 'scripts/package-windows-x64.mjs' - - 'scripts/verify-windows-sandbox-e2e.mjs' - 'scripts/verify-windows-x64.mjs' - 'scripts/verify-windows-sandbox-e2e.mjs' - 'scripts/verify-windows-installer-lifecycle.mjs' @@ -56,6 +55,32 @@ on: - 'scripts/npm-spawn.mjs' - 'scripts/generate-third-party-notices.mjs' - 'scripts/generate-windows-cargo-notices.mjs' + - 'scripts/windows-package-source-closure.mjs' + - 'scripts/windows-package-source-closure.test.mjs' + # The packaged worker and its Windows boundary driver are built from + # these sources, so changes here must reach the packaged lifecycle gate. + - 'apps/desktop/scripts/copy-runtime-filesystem-worker.mjs' + - 'packages/runtime/scripts/build-filesystem-worker.mjs' + - 'packages/runtime/src/filesystem-worker/**' + - 'packages/runtime/src/sandbox/**' + - 'packages/runtime/src/path-containment.ts' + - 'packages/runtime/src/sandbox-boundary-path.ts' + - 'packages/runtime/src/apply-patch-file.ts' + - 'packages/runtime/src/child-fd-input.ts' + - 'packages/runtime/src/child-process-lifecycle.ts' + - 'packages/runtime/src/edit-replace.ts' + - 'packages/runtime/src/file-stable-write.ts' + - 'packages/runtime/src/image-file.ts' + - 'packages/runtime/src/process-tree-terminator.ts' + - 'packages/runtime/src/unified-diff.ts' + - 'packages/core/src/absolute-path.ts' + - 'packages/core/src/artifacts.ts' + - 'packages/core/src/attachments.ts' + - 'packages/core/src/permission-profile.ts' + - 'packages/core/src/permission-profile-compiler.ts' + - 'packages/core/src/sandbox-boundary.ts' + - 'packages/core/src/serialized-byte-length.ts' + - 'packages/core/src/windows-path.ts' - 'experiments/windows-sandbox/launcher/**' - 'experiments/windows-sandbox/*.ps1' - '.gitattributes' diff --git a/package-lock.json b/package-lock.json index 963aae16b8..e21b1e2d41 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "@biomejs/biome": "2.5.9", "@electron/asar": "3.4.1", "@types/node": "^26.2.0", + "esbuild": "^0.28.1", "knip": "^6.32.2", "patch-package": "8.0.1", "typescript": "^7.0.2", diff --git a/package.json b/package.json index 7c5c6c330e..7a363a8030 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ "test:product-release": "node --test scripts/product-release.test.mjs scripts/product-release-artifacts.test.mjs scripts/product-release-authority.test.mjs", "generate:windows-cargo-notices": "node scripts/generate-windows-cargo-notices.mjs", "check:windows-cargo-notices": "node scripts/generate-windows-cargo-notices.mjs --check", - "check:release": "npm run check:stale && npm run check:third-party-notices && npm run check:cli-third-party-notices && npm run check:model-metadata && npm run check:product-release-identity && npm run check:asf-npm && node --test scripts/product-release.test.mjs scripts/product-release-artifacts.test.mjs scripts/product-release-authority.test.mjs scripts/release-cli-file-policy.test.mjs scripts/release-cli-artifact-policy.test.mjs scripts/release-cli-eval-support.test.mjs scripts/release-cli-publication.test.mjs scripts/release-cli-runtime-host-diagnostics.test.mjs scripts/release-cli-workflow-policy.test.mjs scripts/verify-packaged-app.test.mjs scripts/third-party-closure.test.mjs scripts/generate-third-party-notices.test.mjs scripts/source-legal-inventory.test.mjs scripts/sync-model-metadata.test.mjs", + "check:release": "npm run check:stale && npm run check:third-party-notices && npm run check:cli-third-party-notices && npm run check:model-metadata && npm run check:product-release-identity && npm run check:asf-npm && node --test scripts/product-release.test.mjs scripts/product-release-artifacts.test.mjs scripts/product-release-authority.test.mjs scripts/release-cli-file-policy.test.mjs scripts/release-cli-artifact-policy.test.mjs scripts/release-cli-eval-support.test.mjs scripts/release-cli-publication.test.mjs scripts/release-cli-runtime-host-diagnostics.test.mjs scripts/release-cli-workflow-policy.test.mjs scripts/verify-packaged-app.test.mjs scripts/third-party-closure.test.mjs scripts/generate-third-party-notices.test.mjs scripts/source-legal-inventory.test.mjs scripts/sync-model-metadata.test.mjs scripts/windows-package-source-closure.test.mjs", "package:macos-arm64": "node scripts/package-macos-arm64.mjs", "verify:macos-arm64": "node scripts/verify-macos-arm64-dmg.mjs", "package:windows-x64": "node scripts/package-windows-x64.mjs", @@ -91,6 +91,7 @@ "@astryxdesign/core": "0.4.5", "@biomejs/biome": "2.5.9", "@types/node": "^26.2.0", + "esbuild": "^0.28.1", "knip": "^6.32.2", "patch-package": "8.0.1", "typescript": "^7.0.2", diff --git a/scripts/ci-test-plan.mjs b/scripts/ci-test-plan.mjs index cd56017bd6..fc222380c1 100644 --- a/scripts/ci-test-plan.mjs +++ b/scripts/ci-test-plan.mjs @@ -62,6 +62,8 @@ const RELEASE_CONTRACT_FILES = new Set([ 'scripts/verify-windows-installer-lifecycle.mjs', 'scripts/verify-windows-x64.mjs', 'scripts/windows-upgrade-baseline.json', + 'scripts/windows-package-source-closure.mjs', + 'scripts/windows-package-source-closure.test.mjs', ]); const TYPECHECK_ONLY_FILES = new Set([ diff --git a/scripts/ci-test-plan.test.mjs b/scripts/ci-test-plan.test.mjs index 33f2eda812..353a9221d0 100644 --- a/scripts/ci-test-plan.test.mjs +++ b/scripts/ci-test-plan.test.mjs @@ -156,6 +156,8 @@ test('release authority changes select their dedicated contract gate', () => { 'scripts/verify-packaged-app.mjs', 'scripts/verify-windows-x64.mjs', 'scripts/windows-upgrade-baseline.json', + 'scripts/windows-package-source-closure.mjs', + 'scripts/windows-package-source-closure.test.mjs', ]) { assert.equal(planTests([path], { graph }).releaseContract, true, path); } @@ -363,6 +365,35 @@ test('the packaged Windows gate owns Runtime Host candidate election changes', ( assert.match(workflow, /'packages\/runtime-host\/src\/client\/launcher\.ts'/u); }); +test('the packaged Windows gate triggers on packaged sandbox inputs', () => { + const workflow = readWorkflow('release-windows-check.yml'); + + for (const path of [ + 'apps/desktop/scripts/copy-runtime-filesystem-worker.mjs', + 'packages/runtime/scripts/build-filesystem-worker.mjs', + 'packages/runtime/src/filesystem-worker/**', + 'packages/runtime/src/sandbox/**', + 'packages/runtime/src/path-containment.ts', + 'packages/runtime/src/sandbox-boundary-path.ts', + 'packages/core/src/permission-profile.ts', + 'packages/core/src/permission-profile-compiler.ts', + ]) { + assert.ok(workflow.includes(` - '${path}'`), path); + } +}); + +test('pull-request and release lanes share the packaged sandbox lifecycle verifier', () => { + for (const name of ['release-windows-check.yml', 'release.yml']) { + assert.match(readWorkflow(name), /npm run verify:windows-x64/u, name); + } + + const verifier = readFileSync(new URL('verify-windows-x64.mjs', import.meta.url), 'utf8'); + assert.match( + verifier, + /await verifyPackagedWindowsSandboxLifecycle\(sandboxExecutable, \{ run \}\)/u, + ); +}); + test('specialized platform workflows stay reachable without pull requests', () => { const cli = readWorkflow('cli-package-validation.yml'); const baseline = readWorkflow('windows-baseline.yml'); diff --git a/scripts/verify-windows-harness.test.mjs b/scripts/verify-windows-harness.test.mjs index 6e94fa9926..7be7addc56 100644 --- a/scripts/verify-windows-harness.test.mjs +++ b/scripts/verify-windows-harness.test.mjs @@ -37,6 +37,7 @@ import { waitForUsableRenderer, } from './verify-packaged-app.mjs'; import { waitForInstalledProductVersion } from './verify-windows-autoupdate.mjs'; +import { verifyPackagedWindowsSandboxLifecycle } from './verify-windows-x64.mjs'; import { deleteUninstallRegistrationForInstall, readUninstallDisplayVersionsForInstall, @@ -149,6 +150,41 @@ describe('rendererLayoutMatchesViewport', () => { }); }); +it('runs packaged sandbox lifecycle evidence against the exact shipped broker', async () => { + const calls = []; + const sandboxExecutable = 'C:\\release\\resources\\windows-sandbox\\maka-windows-sandbox.exe'; + + await verifyPackagedWindowsSandboxLifecycle(sandboxExecutable, { + run: async (command, args) => { + calls.push({ command, args }); + return { stdout: '', stderr: '' }; + }, + }); + + assert.deepEqual( + calls.map(({ command, args }) => ({ + command, + script: args[2].replaceAll('\\', '/').split('/').at(-1), + launcherFlag: args[3], + launcher: args[4], + })), + [ + { + command: 'pwsh', + script: 'appcontainer-smoke.ps1', + launcherFlag: '-LauncherPath', + launcher: sandboxExecutable, + }, + { + command: 'pwsh', + script: 'acl-recovery-smoke.ps1', + launcherFlag: '-LauncherPath', + launcher: sandboxExecutable, + }, + ], + ); +}); + it('uses the product SemVer contract throughout Windows release verification', () => { assert.equal(installerVersion('Maka-1.2.3-beta.2-win-x64.exe'), '1.2.3-beta.2'); assert.equal(bumpedAutoupdateVersion('1.2.3-beta.2'), '1.2.3'); diff --git a/scripts/verify-windows-x64.mjs b/scripts/verify-windows-x64.mjs index 6b2bf36265..2aea9189bc 100644 --- a/scripts/verify-windows-x64.mjs +++ b/scripts/verify-windows-x64.mjs @@ -76,6 +76,21 @@ export function assertWindowsProductVersion(productVersion, expectedVersion) { } } +export async function verifyPackagedWindowsSandboxLifecycle( + sandboxExecutable, + { run = runCommandFromRepo } = {}, +) { + for (const script of ['appcontainer-smoke.ps1', 'acl-recovery-smoke.ps1']) { + await run('pwsh', [ + '-NoProfile', + '-File', + join(repoRoot, 'experiments', 'windows-sandbox', script), + '-LauncherPath', + sandboxExecutable, + ]); + } +} + // The Windows build is unsigned, so the only architecture evidence in the // artifact is the PE header of the executable itself. export async function readPeMachine(path) { @@ -204,6 +219,9 @@ export async function verifyPackagedWindowsApp( sandboxExecutable, ]); + step('verifying packaged sandbox lifecycle recovery'); + await verifyPackagedWindowsSandboxLifecycle(sandboxExecutable, { run }); + step('running real filesystem-worker operations through the packaged app'); // The evidence executes the packaged artifacts themselves: the packaged // broker, the packaged Electron executable as the worker runtime and the diff --git a/scripts/windows-package-source-closure.mjs b/scripts/windows-package-source-closure.mjs new file mode 100644 index 0000000000..6b7eefe958 --- /dev/null +++ b/scripts/windows-package-source-closure.mjs @@ -0,0 +1,136 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { existsSync, readFileSync } from 'node:fs'; +import { extname, join, relative, resolve, sep } from 'node:path'; +import { build } from 'esbuild'; +import { parse as parseYaml } from 'yaml'; + +const defaultRepoRoot = resolve(import.meta.dirname, '..'); +const sourceExtensions = ['.ts', '.tsx', '.mts', '.cts', '.js', '.mjs', '.cjs', '.json']; + +export const windowsPackageSourceEntrypoints = [ + 'packages/runtime/src/filesystem-worker/index.ts', + 'packages/runtime/src/filesystem-worker/worker-entry.ts', + 'packages/runtime/src/sandbox/index.ts', +]; + +export async function collectWindowsPackageSourceClosure(repoRoot = defaultRepoRoot) { + const workspaces = loadWorkspacePackages(repoRoot); + const result = await build({ + absWorkingDir: repoRoot, + bundle: true, + entryPoints: windowsPackageSourceEntrypoints, + format: 'esm', + logLevel: 'silent', + metafile: true, + outdir: 'windows-package-source-closure', + packages: 'external', + platform: 'node', + plugins: [workspaceSourcePlugin(repoRoot, workspaces)], + treeShaking: false, + write: false, + }); + return Object.keys(result.metafile.inputs) + .map(normalize) + .filter((path) => path.startsWith('packages/')) + .sort(); +} + +export function readWindowsReleasePathPatterns(repoRoot = defaultRepoRoot) { + const workflowPath = join(repoRoot, '.github', 'workflows', 'release-windows-check.yml'); + const workflow = parseYaml(readFileSync(workflowPath, 'utf8')); + const paths = workflow?.on?.pull_request?.paths; + if (!Array.isArray(paths) || !paths.every((path) => typeof path === 'string')) { + throw new Error('Release Windows check must declare pull_request.paths as strings.'); + } + return paths; +} + +export function windowsReleasePatternCoversSource(path, pattern) { + if (path === pattern) return true; + return pattern.endsWith('/**') && path.startsWith(pattern.slice(0, -2)); +} + +function workspaceSourcePlugin(repoRoot, workspaces) { + return { + name: 'maka-workspace-source', + setup(esbuild) { + esbuild.onResolve({ filter: /^@maka\// }, ({ path: specifier }) => { + const workspace = [...workspaces.values()].find( + ({ name }) => specifier === name || specifier.startsWith(`${name}/`), + ); + if (!workspace) return undefined; + const subpath = + specifier === workspace.name ? '.' : `.${specifier.slice(workspace.name.length)}`; + const exported = exportTarget(workspace.manifest.exports?.[subpath]); + const candidate = exported + ? resolve(workspace.directory, exported.replace(/^\.\/dist\//u, './src/')) + : resolve(workspace.directory, 'src', specifier.slice(workspace.name.length + 1)); + return { path: resolveSourceCandidate(candidate, repoRoot, specifier) }; + }); + }, + }; +} + +function resolveSourceCandidate(candidate, repoRoot, specifier) { + const extension = extname(candidate); + const candidates = [candidate]; + if (['.js', '.mjs', '.cjs'].includes(extension)) { + const stem = candidate.slice(0, -extension.length); + candidates.push(...sourceExtensions.map((sourceExtension) => `${stem}${sourceExtension}`)); + } else if (!extension) { + candidates.push(...sourceExtensions.map((sourceExtension) => `${candidate}${sourceExtension}`)); + candidates.push( + ...sourceExtensions.map((sourceExtension) => join(candidate, `index${sourceExtension}`)), + ); + } + const resolved = candidates.find((path) => existsSync(path)); + if (!resolved || !normalize(relative(repoRoot, resolved)).startsWith('packages/')) { + throw new Error( + `Unable to resolve local Windows package import ${specifier} from ${candidate}`, + ); + } + return resolved; +} + +function exportTarget(value) { + if (typeof value === 'string') return value; + if (!value || typeof value !== 'object') return undefined; + for (const condition of ['types', 'import', 'default']) { + const target = exportTarget(value[condition]); + if (target) return target; + } + return undefined; +} + +function loadWorkspacePackages(repoRoot) { + const rootManifest = JSON.parse(readFileSync(join(repoRoot, 'package.json'), 'utf8')); + const result = new Map(); + for (const workspacePath of rootManifest.workspaces ?? []) { + const directory = resolve(repoRoot, workspacePath); + const manifest = JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8')); + result.set(manifest.name, { directory, manifest, name: manifest.name }); + } + return result; +} + +function normalize(path) { + return path.split(sep).join('/'); +} diff --git a/scripts/windows-package-source-closure.test.mjs b/scripts/windows-package-source-closure.test.mjs new file mode 100644 index 0000000000..9c39cb28fa --- /dev/null +++ b/scripts/windows-package-source-closure.test.mjs @@ -0,0 +1,53 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + collectWindowsPackageSourceClosure, + readWindowsReleasePathPatterns, + windowsReleasePatternCoversSource, +} from './windows-package-source-closure.mjs'; + +test('the Windows package trigger covers the packaged worker and driver import closure', async () => { + const closure = await collectWindowsPackageSourceClosure(); + const patterns = readWindowsReleasePathPatterns(); + const missing = closure.filter( + (sourcePath) => + !patterns.some((pattern) => windowsReleasePatternCoversSource(sourcePath, pattern)), + ); + + for (const expected of [ + 'packages/core/src/absolute-path.ts', + 'packages/core/src/sandbox-boundary.ts', + 'packages/core/src/serialized-byte-length.ts', + 'packages/core/src/windows-path.ts', + 'packages/runtime/src/child-fd-input.ts', + 'packages/runtime/src/child-process-lifecycle.ts', + 'packages/runtime/src/process-tree-terminator.ts', + ]) { + assert.ok(closure.includes(expected), `closure omitted ${expected}`); + } + assert.deepEqual(missing, []); +}); + +test('the Windows package workflow path list has no duplicate entries', () => { + const patterns = readWindowsReleasePathPatterns(); + assert.equal(new Set(patterns).size, patterns.length); +});